View all results

Host Values

Host values let an embedding application expose Go-managed resources and objects to FQL scripts. This page covers how to implement them in Go. For the FQL perspective — what host values look like inside a script and which operations they support — see Host Values and Capability Types.

When to implement a host value

  • Resource handle — the Go object owns an external resource (database connection, file handle, HTTP client) whose lifecycle outlives a single function call.
  • Lazy value — the value materializes data only when accessed, avoiding upfront cost (cursor, paginated result set).
  • Domain object — the value has rich behavior (property access, iteration, dispatch) that cannot be expressed as a plain array or object.
  • External client — the value wraps a Go client library and exposes its operations through FQL capabilities (query, dispatch, subscribe).
  • Controlled capabilities — you want to expose only certain operations (read-only properties, no iteration) rather than giving scripts full access to the underlying data.

Choosing the right mechanism

Mechanism Best for Script sees Lifecycle
Parameter Static data: strings, numbers, config, JSON-like structures @name — a plain value Host sets before execution
Function Stateless computation: hash, format, fetch fn(args) → result No state between calls
Host value Stateful resource or behavioral object Variable with capabilities: val.prop, for x in val, query ... in val Host creates; runtime tracks io.Closer
Module Self-contained extension bundling functions + hooks Namespaced functions and values Module Register + engine lifecycle hooks

When in doubt, walk through these questions:

Question If yes If no
Does the Go object own a resource that must be closed? Host value (implement io.Closer)
Does the script need to access properties, iterate, query, or dispatch? Host value (implement capability interfaces)
Is the data static and known before execution? Parameter (WithRuntimeParam)
Is it a one-shot computation with no state? Function
Does it bundle multiple functions + hooks? Module Function or host value

The Value interface

Every host value must satisfy runtime.Value:

example.go
read-only
type Value interface { fmt.Stringer // String() string Hashable // Hash() uint64 Copy() Value }
  • String() — text representation used by to_string() and encoding fallbacks.
  • Hash() — identity hash used by the VM for deduplication and map keys. Use fnv.New64a from the standard library.
  • Copy() — shallow copy. For host values that wrap a pointer, returning the same pointer is usually correct.

Optionally implement Typed so the runtime can report your type in error messages and enable generic argument casting with CastArg:

example.go
read-only
type Typed interface { Type() Type }

Create a type with runtime.NewTypeFor:

example.go
read-only
var MyValueType = runtime.NewTypeFor[*MyValue]()

Minimal implementation

The smallest possible host value — a struct that wraps a string:

example.go
read-only
package main import ( "context" "fmt" "hash/fnv" "log" "github.com/MontFerret/ferret/v2" "github.com/MontFerret/ferret/v2/pkg/runtime" "github.com/MontFerret/ferret/v2/pkg/source" ) type Label struct { text string } var LabelType = runtime.NewTypeFor[*Label]() func (l *Label) Type() runtime.Type { return LabelType } func (l *Label) String() string { return l.text } func (l *Label) Hash() uint64 { h := fnv.New64a() h.Write([]byte(l.text)) return h.Sum64() } func (l *Label) Copy() runtime.Value { return &Label{text: l.text} }

Register a function that returns it:

example.go
read-only
engine, err := ferret.New( ferret.WithFunctionsRegistrar(func(ns runtime.Namespace) { ns.Function().A1().Add("label", func(ctx context.Context, arg runtime.Value) (runtime.Value, error) { text, err := runtime.CastArg[runtime.String](arg, 0) if err != nil { return nil, err } return &Label{text: string(text)}, nil }) }), )
example.fql
read-only
let l = label("urgent") return to_string(l) // "urgent"

Adding capabilities

A host value without capability interfaces is opaque — scripts can hold it and pass it around, but cannot access properties, iterate over it, or query it. Implement capability interfaces to enable FQL operations on the value.

Property and index access

Interface Method FQL syntax
KeyReadable Get(ctx, key Value) (Value, error) val.prop, val["key"]
KeyLookup Lookup(ctx, key Value) (Value, bool, error) val?.prop
KeyWritable Set(ctx, key, value Value) error val.prop = x
KeyRemovable RemoveKey(ctx, key Value) error delete val.prop
IndexReadable At(ctx, idx Int) (Value, error) val[0]
IndexLookup LookupAt(ctx, index Int) (Value, bool, error) val?[0]
IndexWritable SetAt(ctx, idx Int, value Value) error val[0] = x
IndexRemovable RemoveAt(ctx, idx Int) (Value, error) delete val[0]

Adding property access to the Label type:

example.go
read-only
func (l *Label) Get(_ context.Context, key runtime.Value) (runtime.Value, error) { switch key.String() { case "text": return runtime.NewString(l.text), nil case "length": return runtime.NewInt(len(l.text)), nil default: return runtime.None, nil } }
example.fql
read-only
let l = label("urgent") return l.text // "urgent"

Iteration and measurement

Interface Method FQL syntax
Iterable Iterate(ctx) (Iterator, error) for x in val
Iterator Next(ctx) (value, key Value, err error) (returned by Iterate)
Measurable Length(ctx) (Int, error) length(val)
Containable Contains(ctx, value Value) (Boolean, error) x in val

Iterator.Next must return io.EOF when the sequence is exhausted. If the iterator holds resources (an open cursor, for example), implement io.Closer on it as well.

Bulk snapshots for spread

Array spread accepts values that implement runtime.List, and object spread accepts values that implement runtime.Map. Ferret can enumerate those interfaces directly, so host values do not need another capability for correctness.

Remote or paginated collections can optionally provide a native bulk representation:

example.go
read-only
type ListSnapshotter interface { Snapshot(context.Context) (*Array, error) } type MapSnapshotter interface { Snapshot(context.Context) (*Object, error) }

When a valid List or Map implements the matching snapshot interface, spread calls Snapshot once and copies from the returned native Array or Object. A successful call must return a non-nil value. If it returns an error, spread propagates that error without retrying through element-by-element or property-by-property enumeration.

The snapshot interfaces are optional optimizations. Implementing ListSnapshotter without runtime.List, or MapSnapshotter without runtime.Map, does not make a value spreadable.

Snapshot is a bulk read of the collection’s current contents. It does not promise transactional or atomic point-in-time consistency for a remote source unless the host implementation provides that guarantee. It also does not transfer ownership and is separate from vm.Materialize, which performs terminal conversion of a VM result with explicit lifecycle semantics.

Query, dispatch, and observe

Interface Key method FQL syntax
Queryable Query(ctx, Query) (List, error) + 3 modifiers query ... in val using ...
Dispatchable Dispatch(ctx, DispatchEvent) error dispatch "event" TO val / val <- event
Observable Subscribe(ctx, Subscription) (Stream, error) waitfor event "name" in val

The Queryable interface has four methods: Query, QueryOne, QueryCount, and QueryExists. If your implementation only needs the list-returning Query, delegate the other three to the built-in helpers:

example.go
read-only
func (s *Store) QueryOne(ctx context.Context, q runtime.Query) (runtime.Value, error) { return runtime.DefaultQueryOne(ctx, q, s.Query) } func (s *Store) QueryCount(ctx context.Context, q runtime.Query) (runtime.Int, error) { return runtime.DefaultQueryCount(ctx, q, s.Query) } func (s *Store) QueryExists(ctx context.Context, q runtime.Query) (runtime.Boolean, error) { return runtime.DefaultQueryExists(ctx, q, s.Query) }

The Query struct carries the expression, dialect, parameters, and options from the FQL statement:

example.go
read-only
type Query struct { Expression String // the query text Kind String // dialect from USING (empty = default) Params Value // input from WITH Options Value // execution policy from OPTIONS }

Comparison and other capabilities

Interface Method Purpose
Equatable Equal(ctx, other Value) (bool, error) Enables host-defined ==, !=, membership, grouping, and deduplication
Comparable Compare(ctx, other Value) (Ordering, error) Enables relational comparison and sorting
Sortable SortAsc(ctx) error, SortDesc(ctx) error In-place sort
Cloneable Clone(ctx) (Cloneable, error) Deep copy
Unwrappable Unwrap() any Extract the inner Go value
DebugInspectable DebugInfo() DebugInfo Presentation hints for the debugger

Equatable and Comparable are independent. Equal must be reflexive, symmetric, and transitive within the value’s compatible comparison domain, and must return false without an error for an incompatible value. Compare must provide a reflexive, antisymmetric, and transitive order within that domain and return an error compatible with runtime.ErrInvalidOperation for an incompatible value. If both interfaces are implemented, Equal must return true exactly when Compare returns runtime.Equal.

Semantically equal values must return the same Hash(). A hash is only a candidate selector; Ferret verifies Equal before grouping or deduplicating host values.

Both methods receive the execution context unchanged and may return operational errors. Any negative or positive Ordering is normalized to runtime.Less or runtime.Greater by runtime dispatch.

Unwrappable is useful when other Go code (custom functions, modules) needs to access the underlying Go object. The runtime.UnwrapAs generic helper simplifies extraction:

example.go
read-only
if db, ok := runtime.UnwrapAs[*sql.DB](arg); ok { // use the database connection }

Returning host values from functions

A common pattern is a namespaced function that opens a resource and returns it as a host value. The following example implements a minimal in-memory store that supports query ... in:

example.go
read-only
package main import ( "context" "fmt" "hash/fnv" "io" "log" "strings" "github.com/MontFerret/ferret/v2" "github.com/MontFerret/ferret/v2/pkg/runtime" "github.com/MontFerret/ferret/v2/pkg/source" ) type Record struct { Name string Age int } type Store struct { records []Record } var StoreType = runtime.NewTypeFor[*Store]() func NewStore() *Store { return &Store{ records: []Record{ {"Alice", 30}, {"Bob", 17}, {"Carol", 25}, }, } } func (s *Store) Type() runtime.Type { return StoreType } func (s *Store) String() string { return "Store" } func (s *Store) Hash() uint64 { h := fnv.New64a() h.Write([]byte("store")) return h.Sum64() } func (s *Store) Copy() runtime.Value { return s } func (s *Store) Close() error { return nil } func (s *Store) Query(_ context.Context, q runtime.Query) (runtime.List, error) { out := runtime.NewArray(len(s.records)) for _, r := range s.records { if strings.Contains(strings.ToLower(q.Expression.String()), "age > 18") && r.Age <= 18 { continue } obj := runtime.NewObject() obj.Set(context.Background(), runtime.NewString("name"), runtime.NewString(r.Name)) obj.Set(context.Background(), runtime.NewString("age"), runtime.NewInt(r.Age)) out.Append(context.Background(), obj) } return out, nil } func (s *Store) QueryOne(ctx context.Context, q runtime.Query) (runtime.Value, error) { return runtime.DefaultQueryOne(ctx, q, s.Query) } func (s *Store) QueryCount(ctx context.Context, q runtime.Query) (runtime.Int, error) { return runtime.DefaultQueryCount(ctx, q, s.Query) } func (s *Store) QueryExists(ctx context.Context, q runtime.Query) (runtime.Boolean, error) { return runtime.DefaultQueryExists(ctx, q, s.Query) } func main() { ctx := context.Background() engine, err := ferret.New( ferret.WithFunctionsRegistrar(func(ns runtime.Namespace) { db := ns.Namespace("db") db.Function().A0().Add("open", func(ctx context.Context) (runtime.Value, error) { return NewStore(), nil }) }), ) if err != nil { log.Fatal(err) } defer engine.Close() plan, err := engine.Compile(ctx, source.NewAnonymous(` let db = db::open() return query "SELECT * WHERE age > 18" in db `)) if err != nil { log.Fatal(err) } defer plan.Close() session, err := plan.NewSession(ctx) if err != nil { log.Fatal(err) } defer session.Close() output, err := session.Run(ctx) if err != nil { log.Fatal(err) } fmt.Println(string(output.Content)) // [{"age":25,"name":"Carol"},{"age":30,"name":"Alice"}] }

Passing host values as parameters

Instead of creating a host value inside a function, you can pass one directly as a parameter with WithRuntimeParam or WithSessionRuntimeParam:

example.go
read-only
store := NewStore() engine, err := ferret.New( ferret.WithRuntimeParam("db", store), )
example.fql
read-only
return query "SELECT * WHERE age > 18" in @db

This is useful when the host application controls the resource lifecycle and wants to share a single instance across multiple query executions. The engine-level parameter is available to every session without re-creation.

For per-session values, use WithSessionRuntimeParam:

example.go
read-only
session, err := plan.NewSession(ctx, ferret.WithSessionRuntimeParam("db", store), )

Cleanup and finalization

The runtime automatically tracks values that implement io.Closer:

  1. During execution, any io.Closer value encountered in the result is added to a tracked closer set.
  2. After encoding the output, the runtime calls Close() on every tracked closer.
  3. Errors from Close() are propagated to the caller of Session.Run.

This means a host value returned by a function inside a script is encoded first, then closed. The host receives the encoded output; the value is already closed by the time Run returns.

For values referenced multiple times, implement the Resource interface to prevent double-closing:

example.go
read-only
type Resource interface { io.Closer ResourceID() uint64 }

ResourceID must return a stable, unique identifier for the resource. The runtime uses it to deduplicate closers — two references to the same resource result in a single Close call.

Values stored in engine-level parameters are not tracked by the result’s closer set. The host owns their lifecycle and must close them explicitly when the engine shuts down.

Context and cancellation

The VM observes query cancellation at structural execution boundaries, such as loop backedges, calls, debugger observations, and top-level completion. Native arithmetic, comparison, property access, and collection work between those boundaries is atomic from the VM’s perspective.

Context-aware host functions and capabilities receive the original execution context. An implementation that waits, performs network or database I/O, advances a remote iterator, or otherwise retains control must observe that context itself:

example.go
read-only
func (s *Store) Query(ctx context.Context, q runtime.Query) (runtime.List, error) { select { case result := <-s.query(q): return result, nil case <-ctx.Done(): return nil, ctx.Err() } }

Return cancellation with %w when adding context. Errors compatible with context.Canceled or context.DeadlineExceeded terminate execution directly and cannot be caught by on error, retry recovery, or another protected FQL operation.

io.Closer has no context parameter. Its Close call is treated as one atomic operation.

Error behavior

When a capability method returns a non-cancellation error, the VM raises an FQL runtime error at the point of the operation. The error message includes the value’s type and the Go error text. FQL scripts can recover from eligible errors with on error .... Cancellation and deadline errors always propagate to the embedding caller. See Error Handling.

When the script attempts an operation that requires a capability the value does not implement (for example, for x in val on a non-Iterable value), the VM raises a type error listing the expected capability.

Use the runtime error helpers to return well-formatted errors from your capability methods:

example.go
read-only
runtime.Error(runtime.ErrNotFound, "record does not exist") runtime.TypeError(runtime.TypeOf(val), runtime.TypeIterable)

The Proxy shortcut

For cases where a Go type already implements some capability interfaces, sdk.Proxy[T] avoids the boilerplate of implementing Value manually. The proxy wraps any Go value and delegates capability calls via type assertion:

example.go
read-only
import "github.com/MontFerret/ferret/v2/pkg/sdk" proxy := sdk.NewProxy[*MyDB](myDB)

If *MyDB implements Queryable, calls to proxy.Query delegate to myDB.Query. If it does not implement Iterable, proxy.Iterate returns a typed error. The proxy also handles Value methods (String, Hash, Copy), Typed, Unwrappable, json.Marshaler, and equality delegation automatically.

For Go maps and slices, use the specialized variants:

example.go
read-only
proxyMap := sdk.NewProxyMap[string, *User](users) proxySlice := sdk.NewProxySlice[*Record](records)

ProxyMap automatically implements KeyReadable, KeyWritable, and Iterable for map[string]V types. ProxySlice implements IndexReadable, IndexWritable, and Sortable.

Next steps