ferry

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 9, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

README

ferry

Two-way data mapping for Go structs: load from any source, dump to any sink.

CI codecov Go Reference

One annotated struct, one tag grammar, two directions. The same Config type reads from a YAML file, from environment variables, and from a Consul-shaped key-value store, and writes back to any of them that can be written to.

go get github.com/onhotpath/ferry
go get github.com/onhotpath/ferry/driver/yaml

A worked example

type Config struct {
	Name    string        `ferry:"name,required"`
	Timeout time.Duration `ferry:"timeout,default=30s"`
	DB      DB            `ferry:"db"`
	Tags    []string      `ferry:"tags"`
}

type DB struct {
	Host string `ferry:"host"`
	Port int    `ferry:"port,default=5432"`
}

Given app.yaml:

# the service this config is for
name: checkout
db:
  host: db.internal
tags:
  - a

load it, change something, and write it back:

cfg, err := ferry.Load[Config](ctx, yaml.NewSource("app.yaml"))
// {Name:checkout Timeout:30s DB:{Host:db.internal Port:5432} Tags:[a]}

cfg.Tags = append(cfg.Tags, "b")

err = ferry.Dump(ctx, cfg, yaml.NewSink("app.yaml"))

The file afterwards:

# the service this config is for
name: checkout
db:
  host: db.internal
  port: 5432
tags:
  - a
  - b
timeout: 30s

The comment survived, and so did the key order. Saving edits the file rather than replacing it, so only the keys your struct names are touched.

timeout was written because the field holds a value, and port because a default was applied on the way in. A default is applied when the plane holds nothing at that address, and it is text parsed by exactly the parser that field's own kind uses, so default=30s and a timeout: 30s in the file mean the same thing (ADR-0006).

Why it exists

Most Go configuration libraries go one way. They fill a struct from somewhere and stop, so writing the same struct back means a second set of tags, a second mapping, and a second place for the two to drift apart.

ferry drives both directions off one annotation, over a backend it has no opinion about. Three things follow from that rather than being features bolted on top:

  • A config file a person maintains stays one. A save through driver/yaml edits the document in place, so comments, key order and keys your struct does not map all survive.
  • Plane-to-plane transfer is free. Load from one source and dump to another sink, with no intermediate format: a YAML file into a KV store is two calls. examples/planetransfer is the runnable version, and it names what the trip through the struct costs.
  • A backend is two methods. Bind is handed the address set your type determined, and the function it returns does the I/O. Nothing else is required, and the conformance suite that proves you got it right is one call.

Drivers

module plane directions
driver/env environment variables, layered over .env files load and dump
driver/yaml a YAML file, edited in place load and dump
driver/kv a Consul-shaped key-value store, client supplied by you load and dump, experimental
driver/http one HTTP request's query parameters or header fields load
driver/windows the Windows registry load and dump, experimental

Each is a module of its own, versioned separately, and each has a README of its own behind the link. Loading and dumping are separate interfaces, so a source with no honest write - environment variables are the case - is a compile error at the ferry.Dump call site rather than a runtime refusal.

Anything else is a driver you write.

The six verbs and the two options

cfg, err := ferry.Load[Config](ctx, src)       // build a fresh Config from a source
cfg, err := ferry.LoadOver(ctx, seed, src)     // load over a value that already holds some
err = ferry.Dump(ctx, cfg, sink)               // write a value to a sink
err = ferry.Compile[Config]()                  // check the type maps, with no plane in sight

b, err := ferry.Bind[Config](src)              // hand the source the addresses once
cfg, err := b.Load(ctx)                        // ... and load through it as often as you like

w, err := ferry.BindSink[Config](sink)         // the same split on the write side
err = w.Dump(ctx, cfg)

Load is Bind plus one method with the handle dropped, and Dump is BindSink plus one method, so a program that never holds a binding writes exactly what it wrote before. Hold one where the plane is per request, or where the same load runs on a timer: the compile and the driver's own bind happen once instead of on every call. A binding is safe to use from many goroutines.

ferry.TagKey("env") changes which struct tag key is read. It applies to every struct in that call, so pass it everywhere you load that type.

ferry.WithRegistry(reg) names a registry other than the one core ships. ferry.NewRegistry(codecs...) builds one and reports what it refused: it takes its whole codec set at once, holds core's own type set underneath, and has no mutators, so it is complete on the line it is born. ferry.MustRegistry(codecs...) is the same thing for a package-level var, and panics where the other returns an error. A registry also caches the compiled schema, so it is a value to keep: one per program, or one per test.

Documentation

The guides under docs/guide/ are the long-form documentation:

  • The supported type set - every type ferry carries, in one table, and the sharp edges that are easier to meet in production than to guess at from the rules.
  • Tags, defaults and absence - the whole tag grammar, and what Absent and Null mean to a Go field.
  • Errors - what a failed call carries, how to match on it, and why the message text is not API.
  • Plane compatibility - the second promise ferry makes, its three tiers, and what a representation change costs.
  • The dump lifecycle - the seven stages of a Dump call, and the ladder of what refuses where.
  • Writing a driver - the two required methods, the eight optional interfaces, and the one-call conformance suite.
  • Concurrency - the two axes, the one budget both layers honour, and what stays serial.
  • Watch and reload - why a reload is a Load, the watch helper that streams fresh values off a driver's change callback, and the sharp edges a watch loop inherits.

The design records behind every one of these decisions are in docs/adr/. The ADRs are the specification: where a guide and an ADR disagree, the ADR wins and the guide is wrong. Start with ADR-0001 for what ferry supports and what is ruled out, then ADR-0010 for the shape a caller sees.

Package documentation is on pkg.go.dev.

Status

v0, and deliberately so. v0 is the only place semver allows a decision to be taken back, and ferry is using it (ADR-0002). Both the Go API and the text ferry writes into a plane are still free to move. The trigger for v1 is the tag grammar surviving real use, and the golden table that pins what a plane holds settling (ADR-0013).

The Go floor is 1.26, declared by every module in this repository. This is temporary: the floor returns to 1.27 when Go 1.27 is generally available. errors.AsType is what sets it, and core takes no non-stdlib dependency at all.

Performance

Measured, not claimed. The table is machine-generated from a benchmark run; the harness refuses to run at all unless every library produces the identical struct from the identical source.

The baseline is the same job written out by hand with no mapping layer over it, and it is the floor rather than a competitor: no library beats it, so it is published as the reference the row is read against rather than ranked against one. The results file gives every library's multiple over it, ferry's computed the same way as the rest.

scenario remarks ferry (warm) fastest other library baseline: no mapping layer
env_small five flat fields 4.58µs 697ns (go-envconfig) ferry 6.57x slower 174ns (stdlib)
env_large fifty-one leaves, three levels 63.3µs 11.5µs (go-envconfig) ferry 5.49x slower 2.58µs (stdlib)
yaml_small five fields, parsed per load 24.3µs 31.8µs (viper) ferry 1.31x faster 22.6µs (stdlib)
yaml_large fifty-one leaves, parsed per load 125µs 219µs (viper) ferry 1.74x faster 107µs (stdlib)
dump_large over an existing file; ferry merges 512µs 421µs (koanf) ferry 1.22x slower 268µs (stdlib)
dump_fresh no file at the path; all write whole 358µs 396µs (koanf) ferry 1.11x faster 240µs (stdlib)

Left out of the comparison above because its warm figure measures a different job: xload in yaml_small. The results file says what the difference is, and gives the column where those rows are comparable.

Full results, the machine, the toolchain, the competitor versions, what each library actually did and what was not measured: docs/perf/results.md.

Run on ubuntu-latest, -count 10, -benchtime 1s, Go go1.27rc2.

Licence

Apache 2.0. See LICENSE.

Contributing

CONTRIBUTING.md explains how the repository is organised and what the conventions mean: what the ADRs are and how they are amended, what belongs in a doc comment and what does not, why examples live in example_test.go, and where benchmarks go.

make help lists the developer targets. make check and make lint are what CI runs, and both must be green.

Documentation

Overview

Package ferry is a bidirectional, struct-first data mapper. One annotated struct and one tag grammar drive both directions: Load fills a value from a pluggable source, and Dump writes the same value back to a pluggable sink.

type Config struct {
    Host    string        `ferry:"host,required"`
    Port    int           `ferry:"port,default=8080"`
    Timeout time.Duration `ferry:"timeout,default=30s"`
}

cfg, err := ferry.Load[Config](ctx, yaml.NewSource("app.yaml"))

The store a value is read from or written to is called a plane: a YAML file, the process environment, a KV bucket, a query string. Core knows nothing about any of them, and reaches one only through Source and Sink. Planes ship as separate modules under driver/.

The six verbs

  • Load builds a fresh value of T from a source.
  • LoadOver does the same over a seed the caller supplies, which is how a reload and a composite default are spelled.
  • Dump writes a value to a sink.
  • Compile reports whether a type's annotation is legal, from the type alone, with no value in hand and no plane reachable. It is what a test calls.
  • Bind hands a source the addresses a type names, once, and returns a value to load through many times. Load is Bind plus one method.
  • BindSink is the same on the write side, and Dump is BindSink plus one method.

Every verb takes the same Option values, and there are three of those: TagKey names the struct tag key to read, WithRegistry names the codec table to resolve types against, and MaxConcurrency allows a load to overlap its calls into a plane that said it tolerates overlap.

The tag grammar

Four words, and one of them is punctuation:

tag     =  name *( "," option )  /  "-"
option  =  "required"  /  "omitzero"  /  "default" "=" token
token   =  bare  /  "'" quoted "'"

Host     string `ferry:"host,required"`
Port     int    `ferry:"port,default=8080"`
Comment  string `ferry:"comment,omitzero"`
Greeting string `ferry:"greeting,default='Hello, world'"`
Odd      string `ferry:"'a,b'"`
Skipped  string `ferry:"-"`

Every exported field names the segment it addresses, or is marked "-". ferry never invents a name out of the Go field name, so exporting a field cannot silently change what a program writes to a plane.

A name or a default value containing a comma is single-quoted, and a literal quote inside a quoted token is doubled. Only a leading quote is significant, so default=it's here needs no quoting at all. A word ferry does not have is a refusal rather than a silent no-op, and the diagnostic names what to write in its place.

Each option has exactly one honest direction, so the grammar spends no syntax on saying which: default= and required are Load-side, omitzero is Dump-side.

The type set

A type is claimed by the first of three steps that will have it, and the claim serves both directions, so a type whose two directions would disagree is refused rather than dumped and never loaded.

  1. Type identity: a registered codec, or one of core's own two pinned types.
  2. The text pair: encoding.TextAppender or encoding.TextMarshaler, together with encoding.TextUnmarshaler.
  3. reflect.Kind admission.

Core pins two types by identity, and their representations do not change: time.Duration is a string such as "30s", and time.Time is RFC 3339 with nanoseconds.

A type declaring both halves of the text pair is claimed by it and lands as a string, so net.IP is "192.0.2.1" rather than sixteen raw bytes and slog.Level is "WARN" rather than 4. Half a pair does not compile, and the diagnostic names the method that is missing; an UnmarshalText on a value receiver is a half pair too, because it decodes into a copy. Nothing else is consulted - not json.Marshaler, encoding.BinaryMarshaler or gob.GobEncoder, and not fmt.Stringer, which declares no inverse.

Admitted by kind: bool, string, the five signed and five unsigned integer widths, float32 and float64, and []byte and [N]byte as bytes. A named type over an admitted kind is admitted with it, so `type Port int` round-trips with nothing registered.

Composites contribute addresses rather than values. A struct mints one name segment per exported field, and unexported fields are skipped; a pointer mints no segment of its own; [N]T mints exactly N indices, because the length is part of the type; []T mints one index per element and map[K]V one name per key, both from the value rather than from the type.

A map is keyed by a string or an integer kind, by time.Duration, or by a registered type whose registration declared KeyCodec.AsMapKey. Nothing else keys a map, because the key becomes address text and has to parse back out of it.

chan, func, complex64, complex128, unsafe.Pointer and uintptr are refused. So is a struct that maps no address, and so is a recursive type, whose address set is unbounded. Core carries none of these by default, and registering a codec collapses any of them to a leaf, which is the remedy for every one: what core will not do is guess a representation for you. Every violation in a type is reported rather than the first, each naming the address and the type, sorted.

On Load a leaf accepts its own kind, and additionally accepts a string, whose text is parsed by exactly the parser that leaf's own kind uses. Nothing else coerces. So "0080" is 80 at an int field and never 0, "yes" is not a bool, and a plane's number is refused at a Go string field, which is what keeps a quoted 8080 and an unquoted one distinguishable across a round trip.

Sharp edges

None of these is a defect, and every one is easier to meet in production than to guess at from the rules above.

A time crossing a plane should be UTC. RFC 3339 carries the offset and not the zone identity, so a time.Time that is not UTC loses its zone's DST rules: a stored timestamp is unaffected, but a stored "when to run next" is wrong by an hour for half the year. The Location a load produces is machine-dependent as well, so two machines can load values that are .Equal and not ==. encoding/json/v2 does exactly the same thing and has no zone-preserving option, so this is inherited from RFC 3339 rather than chosen.

An array and a slice are not interchangeable. An array's element addresses are known from the type, so an array loads from a source that cannot enumerate and a slice does not. See Enumerator.

A type admitted by kind gets a representation nobody chose. A [16]byte UUID lands in a YAML file as sixteen raw bytes: it round-trips exactly, it is simply illegible. Register a codec for a type whose stored spelling matters.

[]byte is []uint8 and []rune is []int32, one reflect.Type each, so ferry cannot tell a byte blob from a slice of small unsigned integers and picks bytes, and []rune is an indexed sequence of numbers rather than text.

A named type over time.Duration dumps nanoseconds, because it is a distinct reflect.Type and falls through to its kind. DurationLike is the one-line remedy.

A type claimed by the text pair may not key a map. Its text may well be injective, but nobody was asked, and a registration is the only place that declaration can live.

default=aGk= on a []byte field lands as the four bytes aGk= and not the decoded hi. A declared default is text, and how a plane spells bytes is the driver's business rather than ferry's. Register a codec, or seed the value through LoadOver.

Message text is not API. Match on the sentinels and on the address.

Absence, defaults and zero values

One rule carries all of it: absent means ferry does not write to the field. Every other observation, a null and the empty string included, is a value the plane holds and is handed to the type set, which accepts it or refuses it loudly. So a LoadOver against an empty plane leaves the seed untouched, and an explicit empty beats whatever the field was already carrying.

A null is presence carrying a value, not a second spelling of absence. []byte, *T, []T and map[K]V take it and land on their own nil; every other leaf refuses it as a wrong kind. Nothing is zeroed silently.

A struct merges and a composite replaces: a struct's fields are separate addresses, so the ones the plane does not have are left alone, while a slice or a map the plane has any children under is replaced wholesale. A *T at a leaf is the one shape that tells an explicit zero from an unset field.

A declared default is text, applied when and only when the plane reports absence, and decoded by the field's own parser, so "0080" means 80 in a tag exactly as it does from a plane. It is leaf-only; a composite default is spelled by seeding LoadOver.

required is a presence test and nothing else, so an explicit empty satisfies it. It is not admissible on a slice or a map, where a missing key and an explicit empty list are one observation at a container address.

omitzero compares against the Go zero value, before anything converts it, and is admissible at every type. A field holding its declared default is dumped like any other.

A composite with no elements writes a null at its own address, whether it is nil or empty, and loads back to nil. The nil-versus-empty distinction is not expressible by any type in the set; a user who needs it models it as struct{ Set bool; Items []string }.

Registration

The type set is closed, and its extension is explicit. A registered codec claims a type ferry does not own, in both directions at once, and the guarantee about that type transfers to whoever registered it:

var registry = ferry.MustRegistry(
    ferry.NumberText[big.Int](),
    ferry.DurationLike[PollInterval](),
)

A registration is named after the kind it writes, so it takes no kind argument, and its halves are typed by the payload that kind carries, so a registrant never builds a Value. BoolValue, NumberValue, StringValue and BytesValue take two functions each; NumberKey and StringKey are the two whose kind may key a map, and they alone carry KeyCodec.AsMapKey; NumberText and StringText take no functions, for a type that already carries a text pair and wants a different boundary kind, and they carry AsMapKey too. DurationLike closes the named-duration hole at one line per type, and NullValue is the one modifier: it says what a plane's null becomes and which values write one back.

NewRegistry is the whole registry API and WithRegistry names a registry for one call. A registry takes its whole codec set at construction and has no mutators, so it is complete when it is born and there is no ordering rule between building it and using it, and every refusal a constructor found along the way is reported there. Core's own type set is always underneath, and a codec claiming a type core owns is refused like any duplicate. MustRegistry is the same constructor for the package-level var a refusal cannot be checked on, and it panics.

A registration claims its type unconditionally: there is no decline, and "fall through to the next step" is spelled by not registering the type.

A registry also holds the compiled-schema cache, and nothing evicts from it, so a registry is a value to keep. Build one per program, or one per test.

Tag key extensions

ferry's own tag vocabulary is closed, and a library built on ferry gets a struct tag key of its own instead. WithTagKeys declares one on a registry, beside the codecs:

var registry = ferry.MustRegistry(
    ferry.WithTagKeys(ferry.KeyExtension{
        TagKey: "docs",
        Words:  []ferry.Word{{Name: "desc", TakesValue: true}},
    }),
)

Host string `ferry:"host,required" docs:"desc=where the service lives"`

A declared key is parsed with that declaration's vocabulary, with the same near-miss diagnostics ferry gives its own words, and handed back inert: core validates and never acts. An undeclared key is another library's business and is never claimed, so a json or a validate tag on the same field is untouched. Nothing is added to what ferry's own tag accepts, and ferry:"host,docs.desc=x" is refused exactly as it was.

The words ride the AddressSet, keyed by the address the field named, so a driver reads its own key at its own Bind with AddressSet.Extension and a caller plumbs nothing. A consumer that never meets a plane reads the same table with ExtensionTable.

Addresses

Every place a plane holds something has an address: a Path, an ordered sequence of segments each carrying a kind and a text. /db/host is two name segments, /tags#0 is a name segment followed by an index.

An address also carries what kind of place it names, and the three kinds are separate types. A LeafAddr is a place a Value can be, a SectionAddr is a place whose children are known from the type, and a CompositeAddr is a place whose children come from the value. They partition the address space and they are not interchangeable, so /db as a section and /db as a composite are different addresses.

Only ferry mints one, which is what puts the wrong question out of reach: a driver's Reader.Get takes a leaf, Prober.Probe takes a container and Enumerator.Children takes a composite, so asking a plane for the value of a section is a compile error rather than something to guard against at run time.

Core never joins segments into a plane key, because a separator is plane knowledge. A driver is handed the whole AddressSet before any I/O and does the flattening itself, classifying once with one range over AddressSet.Seq and one type switch. NewKeys is the helper for that, and it checks two things about the result: that the plane can name every address, and that no two addresses collapse onto one plane key. Both refusals land before any backend call.

Errors

A failed call carries a set rather than the first thing that went wrong. Range it with Elements, and match a member with errors.Is against ErrSchema, ErrMissing, ErrValue, ErrPlane, ErrPanic, ErrDriver or ErrReadOnly. Read where it happened with errors.AsType[*ferry.Error] and Error.Address:

for _, e := range ferry.Elements(err) {
    if fe, ok := errors.AsType[*ferry.Error](e); ok {
        log.Println(fe.Address(), errors.Is(fe, ferry.ErrMissing))
    }
}

Message text is not API. Match on the sentinels and on the address, and get exactness from the assertions ferrytest ships. ferry's own text never repeats a value the plane supplied, so a plane holding secrets does not leak them into a log through ferry.

On failure Load returns the zero value and LoadOver returns the seed it was handed; neither ever yields a partly built value. On Dump every value is encoded before any of them is written, so a dump that fails for a reason ferry could have known without touching the plane leaves the plane untouched.

Compatibility

Two promises, and they are not the same one. The API is ordinary semver, at v0 today. What a plane holds is a second promise with three tiers: the representation of a type in core's own set is promised at core's major version; a registered codec's representation is its registrant's, at their major version; and a type admitted by kind or claimed by the text pair has a representation nobody chose and nobody promises. That third tier is large, and the text pair's half of it cannot even be enumerated, since any type in any module may declare one.

A change to a pinned representation is a major version of the module that owns it, and the new ferry cannot read what the old one wrote. The migration is a few lines of ordinary ferry code, and it terminates, because the new codec refuses the old file afterwards.

The design records behind these decisions are in docs/adr/.

Example

Example loads an annotated struct from a plane.

The plane here is [ferrytest.Static], a source of constants, so the example is self-contained. Ordinary use names a driver instead: yaml.NewSource("app.yaml"), env.New(), and so on.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/onhotpath/ferry"
	"github.com/onhotpath/ferry/ferrytest"
)

// Config is the annotated struct the examples below load and dump. One struct
// and one tag grammar serve both directions.
type Config struct {
	Host    string        `ferry:"host,required"`
	Port    int           `ferry:"port,default=8080"`
	Timeout time.Duration `ferry:"timeout,default=30s"`
	Tags    []string      `ferry:"tags"`
	DB      DB            `ferry:"db"`
}

// DB is the nested struct, which contributes /db/user rather than a second
// top-level address.
type DB struct {
	User string `ferry:"user"`
}

func main() {
	src := ferrytest.Static(map[ferry.Path]ferry.Value{
		ferry.At("host"):         ferry.String("db.internal"),
		ferry.At("tags").Elem(0): ferry.String("eu"),
		ferry.At("db", "user"):   ferry.String("checkout"),
	})

	cfg, err := ferry.Load[Config](context.Background(), src)
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Printf("%+v\n", cfg)
}
Output:
{Host:db.internal Port:8080 Timeout:30s Tags:[eu] DB:{User:checkout}}

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrDriver = errors.New("driver")

ErrDriver is provenance rather than a class, and it crosses the others: it says the cause came from below the boundary. Core supplies it, so a driver cannot forge it.

It is the closest thing to a retry signal ferry offers. Whether a particular backend failure is worth retrying is the driver's knowledge, and its own sentinel stays reachable underneath; what ferry can say is that retrying an ErrValue is always pointless and retrying a driver's read is sometimes not.

View Source
var ErrMissing = errors.New("missing")

ErrMissing is the plane being silent at an address the schema marks required. It is kept apart from ErrValue so that "these six keys are unset" and "these two hold garbage" are two lists rather than one.

View Source
var ErrPanic = errors.New("panic")

ErrPanic is a codec that panicked rather than returning, recovered at the address it was called for.

It is its own class because it is neither the plane's fault nor the value's: it is a bug in the codec, reported at the address that reached it and beside every other failure the same run found, so one broken codec costs one address rather than the whole report. The recovered value is in the message.

ferry recovers nothing else. A panic from anywhere but a codec keeps unwinding.

View Source
var ErrPlane = errors.New("plane error")

ErrPlane is ferry being unable to talk to the plane, or a driver refusing the address set it was bound to.

View Source
var ErrReadOnly = errors.New("plane is read only")

ErrReadOnly is a plane that is writable in principle but not right now: a KV with no write ACL, a file sink over an unwritable directory.

A sink raises it when it opens for writing, so a Dump refused this way has written nothing at all rather than half a struct. A driver wraps this and its own error, so errors.Is answers for both.

View Source
var ErrSchema = errors.New("schema error")

ErrSchema is a failure provable from the destination type plus the codec registry, with no plane in sight: a malformed tag, an unsupported type, a contradictory declaration. It is what Compile reports.

View Source
var ErrValue = errors.New("invalid value")

ErrValue is the plane speaking and what it said not fitting the target type.

View Source
var ErrWrongKind = errors.New("value: wrong kind")

ErrWrongKind reports an accessor asked to answer for a kind that has no answer: an absent value holds no string, and a number is not a bool. Every accessor on Value returns it rather than panicking, and it is matched with errors.Is.

It is subordinate to ErrValue rather than a class of its own, so a refusal reaching a caller through core answers errors.Is(err, ferry.ErrValue) too and a caller reading the error set by class never has to know it exists. What it is for is the finer question a codec asks: "I called the wrong accessor", rather than "the plane's value did not fit".

View Source
var Null = nullValue

Null is the null value: the plane has this address and holds its own null there. A driver over a plane whose grammar has no null never writes it.

There is one null and it carries nothing, so it is a value rather than a call, in io.EOF's idiom. Compare with == or with Value.Kind.

Being a var, it can be assigned to, and doing so changes only what this name reads as: ferry's own paths do not go through it, so a program that reassigns it breaks its own comparisons and nothing else. Do not.

View Source
var RootRequired rootRequired

RootRequired declares the root address required, which is the one address no struct tag can name.

port, err := ferry.Load[int](ctx, src, ferry.RootRequired)

A load fails with ErrMissing where the plane holds nothing at the root. Where the root is a leaf that is the presence test required always is, satisfied by any observation the plane makes there and by no other thing, including an explicit empty text and a null. Where the root is a struct it means the plane supplied at least one of that struct's own children, which is what required means at every other section address.

It is a presence test about the plane, so a seed does not answer it: sharp edge, ferry.LoadOver(ctx, 8080, src, ferry.RootRequired) still fails where the plane went silent, which is what a reload wants. Dump accepts it and writes what it was given, because requiredness is a question only a load asks.

It changes what a type compiles to, so it is part of the schema cache key, and it is refused when supplied twice in one call.

View Source
var SectionAbsent = sectionAbsent

SectionAbsent is the plane saying it does not have this address at all.

Being a var, it can be assigned to, and doing so changes only what this name reads as: ferry's own paths do not go through it, so a program that reassigns it breaks its own comparisons and nothing else. Do not.

View Source
var SectionNull = sectionNull

SectionNull is the plane saying the container is there and holds the plane's own null. A driver over a plane whose grammar has no null never returns it, and it carries the same reassignability caveat as SectionAbsent.

View Source
var SectionPresent = sectionPresent

SectionPresent is the plane saying the container is there, possibly holding nothing. It carries the same reassignability caveat as SectionAbsent.

Functions

func Compile

func Compile[T any](opts ...Option) error

Compile reports whether T's annotation is legal, from the type alone, with no value in hand and no plane reachable.

func TestSchema(t *testing.T) {
    if err := ferry.Compile[Config](); err != nil {
        t.Fatal(err)
    }
}

It runs exactly the compiler Load and Dump run, and takes the same Option values, so a type it accepts is a type they accept. It compiles the schema and discards it, so it retains no resolution and is safe anywhere, including during init.

What it checks is the whole annotation: every exported field names the segment it addresses or is marked "-", every named type is in the supported set or has a registered codec, and every declaration is admissible at the type it sits on.

Host     string `ferry:"host,required"`
Greeting string `ferry:"greeting,default='Hello, world'"`
Note     string `ferry:"note,default=it's here"`
Odd      string `ferry:"'a,b'"`
Skipped  string `ferry:"-"`

It returns nil, or one refusal per address, sorted. Range it with Elements, and match a member with errors.Is against ErrSchema.

Example

ExampleCompile checks a type's annotation with no value in hand and no plane reachable, which is what a test does.

package main

import (
	"errors"
	"fmt"
	"time"

	"github.com/onhotpath/ferry"
)

// Config is the annotated struct the examples below load and dump. One struct
// and one tag grammar serve both directions.
type Config struct {
	Host    string        `ferry:"host,required"`
	Port    int           `ferry:"port,default=8080"`
	Timeout time.Duration `ferry:"timeout,default=30s"`
	Tags    []string      `ferry:"tags"`
	DB      DB            `ferry:"db"`
}

// DB is the nested struct, which contributes /db/user rather than a second
// top-level address.
type DB struct {
	User string `ferry:"user"`
}

// Untagged is a type that does not compile: an exported field must name the
// segment it addresses, or be marked "-".
type Untagged struct {
	Host string
}

func main() {
	fmt.Println(ferry.Compile[Config]())
	fmt.Println(errors.Is(ferry.Compile[Untagged](), ferry.ErrSchema))
}
Output:
<nil>
true

func ConcurrencyBudget

func ConcurrencyBudget(ctx context.Context) int

ConcurrencyBudget reports how many calls into the plane the caller allowed to overlap, for a driver that wants to spend it behind its own open.

func (o opener) open(ctx context.Context) (ferry.Reader, error) {
    return o.fetch(ctx, ferry.ConcurrencyBudget(ctx))
}

It is the same number the caller gave MaxConcurrency, and it is one budget rather than two: whatever a driver spends here, core spends no more of it walking. A driver that splits one batch into several requests sizes the split with this, and stays inside what the caller granted.

It returns 1 where the caller set no budget, which means one call at a time. It never returns less than 1, so it is always a legal count of goroutines.

The context it reads is the one handed to the OpenFunc and the one the walk runs under, so a Reader may read it at open and again inside a Get.

func Dump

func Dump[T any](ctx context.Context, v T, sink Sink, opts ...Option) error

Dump writes v to sink. The type is inferred, because the value is in hand.

err := ferry.Dump(ctx, cfg, yaml.Sink{Path: "app.yaml"})

The schema is compiled from T rather than from what v happens to hold, so a Dump and a Load of one type cover the same address set.

A field marked omitzero is skipped where it holds T's zero value. An omitted address gets no write at all rather than a write of nothing, so an omission is not a deletion: a replacing sink and a patching sink read one dump differently and both are correct.

Encoding is a phase before any write, so a Dump that fails for a reason ferry could have known without touching the plane leaves the plane untouched. A sink implementing Committer is exempt, since staging already gives it that property, and it gets both kinds of failure in one report for it.

A Committer is committed only where the walk succeeded, and a Releaser is closed either way, so closed-without-Commit is the abort signal and no driver is ever told that it failed.

Range the failure with Elements.

It is BindSink plus SinkBinding.Dump with the handle dropped. So a call naming no sink at all is refused as a nil plane before the value is looked at: where the sink is nil and v is a nil pointer, the report names the sink.

Example

ExampleDump writes a value to a plane and loads it back.

[ferrytest.MemPlane] is a plane with nothing of its own, so what comes back is what ferry wrote. A real sink is named the same way: yaml.Sink{Path: "app.yaml"}.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/onhotpath/ferry"
	"github.com/onhotpath/ferry/ferrytest"
)

// Config is the annotated struct the examples below load and dump. One struct
// and one tag grammar serve both directions.
type Config struct {
	Host    string        `ferry:"host,required"`
	Port    int           `ferry:"port,default=8080"`
	Timeout time.Duration `ferry:"timeout,default=30s"`
	Tags    []string      `ferry:"tags"`
	DB      DB            `ferry:"db"`
}

// DB is the nested struct, which contributes /db/user rather than a second
// top-level address.
type DB struct {
	User string `ferry:"user"`
}

func main() {
	plane := ferrytest.MemPlane().Open()

	cfg := Config{Host: "db.internal", Port: 5432, Timeout: time.Minute, DB: DB{User: "checkout"}}

	if err := ferry.Dump(context.Background(), cfg, plane.Sink); err != nil {
		fmt.Println(err)

		return
	}

	back, err := ferry.Load[Config](context.Background(), plane.Source)
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(back.Host, back.Port, back.Timeout, back.DB.User)
}
Output:
db.internal 5432 1m0s checkout

func Elements

func Elements(err error) []error

Elements splits a ferry failure into the individual failures it reports.

for _, e := range ferry.Elements(err) {
    if errors.Is(e, ferry.ErrMissing) { ... }
}

A failed call reports every failure that is not a consequence of another one it is already reporting, so a struct with six unset required fields is six elements rather than the first. They are sorted, and the order is the same on every run.

It returns a one-element slice for a single failure, so the loop above reads the same whether one field failed or forty, and nil for a nil error. The slice is the caller's to keep.

Example

ExampleElements ranges the failures one call reported.

Both required fields are unset, and both are reported: a failed call carries every failure rather than the first. Match on the sentinels and on the address, never on the message text.

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/onhotpath/ferry"
	"github.com/onhotpath/ferry/ferrytest"
)

// Required is a type whose two fields must both be present.
type Required struct {
	Host string `ferry:"host,required"`
	User string `ferry:"user,required"`
}

func main() {
	_, err := ferry.Load[Required](context.Background(), ferrytest.Static(nil))

	for _, e := range ferry.Elements(err) {
		fe, ok := errors.AsType[*ferry.Error](e)
		if !ok {
			continue
		}

		fmt.Println(fe.Address(), errors.Is(fe, ferry.ErrMissing))
	}
}
Output:
/host true
/user true

func ErrorAt

func ErrorAt(addr Path, err error) error

ErrorAt attaches an address to an error a driver is returning, for the case core cannot supply one: a driver refusing over a whole address set knows which members it disliked, and core does not.

return ferry.ErrorAt(addr, fmt.Errorf("%w: %s", ferry.ErrPlane, why))

A driver that disliked several may join several of these, and core reports one failure per address, each keeping its own cause and its own class. What a driver returns without an address on it stays whole and is reported as one failure with no address.

A sentence of the driver's own around one of these is kept, so wrapping the result costs nothing:

return fmt.Errorf("flushing the write buffer: %w", ferry.ErrorAt(addr, err))

reports one failure at addr whose text and whose whole chain are both intact. A sentence around several of them is dropped instead, because it describes all of them and the failures are reported one address at a time.

It attaches and never classifies. On its own the result is not an Error and matches no class; core reads the address off it and wraps it. A nil err returns nil.

The result also renders as the error it carries, and never as the address: the address is data for core to read, not text, so a driver that prints one of these rather than returning it sees no address in it.

func Load

func Load[T any](ctx context.Context, src Source, opts ...Option) (T, error)

Load builds a value of T from src. The type is named because there is no value to infer it from; Dump infers its own.

cfg, err := ferry.Load[Config](ctx, yaml.NewSource("app.yaml"))

Every field the plane is silent about keeps T's zero value, and a field declaring default= takes its default there instead. A root that is a leaf has no tag and so declares no default; LoadOver is where it gets one, and the seed is it.

On failure it returns the zero value of T and never a partly built one. Range the failure with Elements, and match a member against ErrSchema, ErrMissing, ErrValue, ErrPlane, ErrPanic, ErrDriver or ErrReadOnly.

It is LoadOver with the zero seed, so anything said about one holds for the other, and it is Bind plus Binding.Load with the handle dropped.

func LoadOver

func LoadOver[T any](ctx context.Context, seed T, src Source, opts ...Option) (T, error)

LoadOver builds a value of T from src, over a seed the caller supplies.

It has three uses. A seed is how a composite default is spelled, since a struct tag holds one text and a composite's value lives at many addresses; it is also the only default the root has, because a declared default is written on a tag and the root has no tag; and a reload is the caller writing the carry-over out loud rather than getting it from a destination that happens to be populated:

cfg, err := ferry.LoadOver(ctx, Config{Tags: []string{"default"}}, src)
port, err := ferry.LoadOver(ctx, 8080, src)
cfg, err = ferry.LoadOver(ctx, cfg, src)

An address the plane does not have is absent, and absence does not write, so every field the plane is silent about keeps the value the seed gave it. Where a seed and a declared default both apply to one field the declared default wins, because ferry cannot tell a seeded value from a zero one.

On failure it returns the seed it was handed, unchanged. The walk builds into a copy, so a partly built value is never reachable from the caller. Range the failure with Elements.

It is Bind plus Binding.LoadOver with the handle dropped. A caller who keeps the handle gets the same load and skips the compile and the bind.

Example

ExampleLoadOver loads over a seed, which is how a composite default is spelled and how a reload carries the previous value forward.

The plane names only /host, so every other field keeps what the seed gave it - except where a tag declares a default, which beats a seed.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/onhotpath/ferry"
	"github.com/onhotpath/ferry/ferrytest"
)

// Config is the annotated struct the examples below load and dump. One struct
// and one tag grammar serve both directions.
type Config struct {
	Host    string        `ferry:"host,required"`
	Port    int           `ferry:"port,default=8080"`
	Timeout time.Duration `ferry:"timeout,default=30s"`
	Tags    []string      `ferry:"tags"`
	DB      DB            `ferry:"db"`
}

// DB is the nested struct, which contributes /db/user rather than a second
// top-level address.
type DB struct {
	User string `ferry:"user"`
}

func main() {
	seed := Config{Host: "localhost", Tags: []string{"default"}}

	src := ferrytest.Static(map[ferry.Path]ferry.Value{
		ferry.At("host"): ferry.String("db.internal"),
	})

	cfg, err := ferry.LoadOver(context.Background(), seed, src)
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Printf("%+v\n", cfg)
}
Output:
{Host:db.internal Port:8080 Timeout:30s Tags:[default] DB:{User:}}
Example (Root)

ExampleLoadOver_root loads a bare value, which sits at the root address.

The root is the one address no struct tag names, so it declares no default and the seed is the only one it has. The plane here holds nothing, so 8080 is what comes back.

package main

import (
	"context"
	"fmt"

	"github.com/onhotpath/ferry"
	"github.com/onhotpath/ferry/ferrytest"
)

func main() {
	src := ferrytest.Static(map[ferry.Path]ferry.Value{})

	port, err := ferry.LoadOver(context.Background(), 8080, src)
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(port)
}
Output:
8080

Types

type AddressSet

type AddressSet struct {
	// contains filtered or unexported fields
}

AddressSet is the set of addresses a compiled schema determines, and it is what Source.Bind and Sink.Bind are handed. Holding it before any I/O is what lets a driver precompute its plane keys once per schema and check them: see NewKeys.

Every member is typed: a LeafAddr for a place a value can be, a SectionAddr for a place whose children come from the type, a CompositeAddr for a place whose children come from the value. That is what lets a driver decide once, before any I/O, which question each address admits, rather than inferring it per call from the address text.

It does not contain the addresses a value mints - a map key, a sequence index - because those do not exist until there is a value. A driver that treats its precomputed table as a closed set will refuse a legal write, which is why Keys.Open hands back a function rather than a map.

It is sorted segment-wise, and AddressSet.Seq enumerates it in that order.

func (*AddressSet) Extension

func (a *AddressSet) Extension(key string) (map[Path]map[string]string, bool)

Extension is one declared tag key's address-keyed view: for each address in this set whose field carried that key, the words it carried and the text of each.

func (s Sink) Bind(addrs *ferry.AddressSet) (ferry.OpenWriterFunc, error) {
    view, declared := addrs.Extension("yamlext")
    nodeTags := map[ferry.Path]string{}
    for addr, words := range view {
        nodeTags[addr] = words["node"]
    }
    ...
}

It is how a driver reads its own key without a caller plumbing anything: the registry carries the declaration, this set carries the table, and the sink is still constructed the way it always was. Reading it once at Bind is the whole idiom, since the answer is a property of the schema and not of a call.

The second result reports the key having been declared on the registry this schema was compiled against. A declared key no field carried yields an empty view and true; a key nobody declared yields an empty view and false. Neither is an error, so a driver whose words are an optional annotation may discard it, and a driver whose absent word means something it would act on should refuse at Bind instead of reading a missing declaration as a struct with no annotations.

A driver sees extension data only for addresses it was bound to, and the view is freshly allocated and the caller's to keep.

What is in it is inert to ferry: core validated the words against their declaration and acts on none of them. Acting is yours, and so is the proof that what you write can be read back.

func (*AddressSet) Has

func (a *AddressSet) Has(m Member) bool

Has reports whether the set holds this address, at this kind.

The kind is part of the question. A set holding /db as a section answers false for /db as a composite, because the two are different addresses that admit different questions.

func (*AddressSet) Len

func (a *AddressSet) Len() int

Len is how many addresses the set holds.

func (*AddressSet) Seq

func (a *AddressSet) Seq() iter.Seq[Member]

Seq enumerates the set segment-wise, one Member at a time. The order is stable across builds of the same schema, so a driver may key a table by position.

for m := range addrs.Seq() {
    switch a := m.(type) {
    case ferry.LeafAddr:      ...
    case ferry.SectionAddr:   ...
    case ferry.CompositeAddr: ...
    }
}

type Binding

type Binding[T any] struct {
	// contains filtered or unexported fields
}

Binding is a source bound to a compiled type: Bind produced it, and every Binding.Load through it skips the work that produced it.

It is safe for use from many goroutines. What it holds is written once, when it is built, and never again, so a handler may keep one and load through it on every request. The driver's own open is called once per load, and a driver whose open is not safe for concurrent calls is not safe behind a Binding.

It holds no resource and there is nothing to close. The plane is opened and released inside each load.

It is not a view of the type. It answers no question about T: it has two methods, and both of them need a context and a plane.

func Bind

func Bind[T any](src Source, opts ...Option) (*Binding[T], error)

Bind compiles T and hands src the addresses T names, once, and returns a value to load through many times.

b, err := ferry.Bind[Config](env.New())   // once, at startup
...
cfg, err := b.Load(ctx)                   // as often as you like

It is Load's own first two steps, stopped where a driver's own Bind stops. So Load is this plus Binding.Load with the handle dropped, and anything true of one is true of the other.

It reaches no plane. A source that cannot see its plane binds cleanly here and fails inside the load, which is where a plane that is not there is refused. What it does refuse is what a driver can see without touching a plane: an address the plane cannot name, and a key function that renders two addresses to one key.

It takes the same Option values every other verb takes, and it retains the schema it compiled for the binding's whole life. Range a failure with Elements, and match a member against ErrSchema or ErrPlane.

Example

ExampleBind binds a source once and loads through it many times, which is what a handler does: the compile and the driver's own bind happen at startup, and each load is the open, the walk and the release.

The plane here is a source of constants so that the example is self-contained. A plane that is the request carries its contents in the context instead, and the shape of the code does not change.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/onhotpath/ferry"
	"github.com/onhotpath/ferry/ferrytest"
)

// Config is the annotated struct the examples below load and dump. One struct
// and one tag grammar serve both directions.
type Config struct {
	Host    string        `ferry:"host,required"`
	Port    int           `ferry:"port,default=8080"`
	Timeout time.Duration `ferry:"timeout,default=30s"`
	Tags    []string      `ferry:"tags"`
	DB      DB            `ferry:"db"`
}

// DB is the nested struct, which contributes /db/user rather than a second
// top-level address.
type DB struct {
	User string `ferry:"user"`
}

func main() {
	src := ferrytest.Static(map[ferry.Path]ferry.Value{
		ferry.At("host"):       ferry.String("db.internal"),
		ferry.At("db", "user"): ferry.String("checkout"),
	})

	b, err := ferry.Bind[Config](src)
	if err != nil {
		fmt.Println(err)

		return
	}

	for range 2 {
		cfg, err := b.Load(context.Background())
		if err != nil {
			fmt.Println(err)

			return
		}

		fmt.Println(cfg.Host, cfg.Port, cfg.DB.User)
	}
}
Output:
db.internal 8080 checkout
db.internal 8080 checkout

func (*Binding[T]) Load

func (b *Binding[T]) Load(ctx context.Context) (T, error)

Load builds a value of T from the plane this binding was bound to.

It is exactly what Load does, minus the compile and the bind, so every rule about absence, defaults, required and the failure report is the same. On failure it returns the zero value of T and never a partly built one.

It is Binding.LoadOver with the zero seed.

func (*Binding[T]) LoadOver

func (b *Binding[T]) LoadOver(ctx context.Context, seed T) (T, error)

LoadOver builds a value of T over a seed the caller supplies, from the plane this binding was bound to.

It is exactly what LoadOver does, minus the compile and the bind. An address the plane does not have is absent, and absence does not write, so every field the plane is silent about keeps the value the seed gave it. On failure it returns the seed it was handed, unchanged.

type Codec

type Codec interface {
	Registration
	// contains filtered or unexported methods
}

Codec is one registration: a type, and both halves of the codec that carries it across the boundary.

It is opaque, and the constructors in this package are the only way to build one: BoolValue, NumberValue, StringValue and BytesValue take two functions each, NumberText and StringText take none, NumberKey and StringKey are the forms that may key a map, DurationLike is a one-line spelling of one of them, and NullValue is a modifier over any of them.

Hand it to NewRegistry, which is the only thing that takes one.

func BoolValue

func BoolValue[T any](enc func(T) (bool, error), dec func(bool) (T, error)) Codec

BoolValue registers a type carried across the boundary as a boolean.

ferry.BoolValue(
    func(f Flag) (bool, error) { return f == On, nil },
    func(b bool) (Flag, error) { if b { return On, nil }; return Off, nil })

T is inferred from either function, so no call site writes a type argument. The encode half returns a bool and the decode half is handed one, so the kind this registration writes is this constructor's and there is no way to declare one kind and emit another.

On Load the decode half sees the plane's own bool, or the bool a plane with no types of its own spelled as text, which is parsed as core's bool leaf parses it: true and false and the spellings strconv.ParseBool takes, and a refusal for everything else. A null never reaches it; NullValue is what takes one.

Both halves are required, and a nil one is refused at the NewRegistry this is handed to rather than at the load that would have called it.

func BytesValue

func BytesValue[T any](enc func(T) ([]byte, error), dec func([]byte) (T, error)) Codec

BytesValue registers a type carried across the boundary as an opaque byte sequence, valid UTF-8 or not.

ferry.BytesValue(
    func(id UUID) ([]byte, error) { return id[:], nil },
    func(b []byte) (UUID, error) { ... })

T is inferred from either function. How a plane spells bytes - base64, hex, raw - is the driver's business, so this says what the value is and never how it is written. A null never reaches the decode half.

Bytes may not key a map, which is why this constructor returns a Codec and not a KeyCodec: an address segment is text, and there is no spelling of arbitrary bytes as text that ferry gets to choose on a registrant's behalf.

func NullValue

func NullValue[T any](inner Codec, load func() (T, error), isNull func(T) bool) Codec

NullValue grafts a null policy onto any registration: what a plane's null becomes, and which values write one back.

ferry.NullValue(
    ferry.StringValue(
        func(l Level) (string, error) { return string(l), nil },
        func(s string) (Level, error) { return Level(s), nil }),
    func() (Level, error) { return "", nil },
    func(l Level) bool { return l == "" })

load is the Load policy: the T a null observation becomes. isNull is the Dump policy: the T values that write a null. Both are required, and it wraps any of the constructors above, so one modifier covers all four kinds.

The law, and it is the sharp edge: isNull(load()) must hold. A policy that loads a sentinel it cannot recognise on the way back makes the round trip lie, silently, and only on the null path. ferrytest.Codec checks it.

It is for the case where null is wanted as a value of T. A *T with no policy at all already writes a null for a nil pointer and loads one back, through ferry's own rules and no codec, and that is what keeps null and the zero distinct; this merges them by design, which is exactly its contract.

So pick one or the other, because a policy under a pointer is neither. At a *T field the pointer's own null wins in both directions: a nil pointer writes a null whatever isNull says, and a null loads as a nil pointer without running load. A policied T dumped through a *T field therefore comes back nil.

It is refused where inner is nil, where either policy is nil, where inner is not a registration for T, and where inner declared itself usable as a map key: a key becomes the segment text of an address and never crosses the boundary as a value, so it has no null to carry and two null-ish keys would render to one empty segment. Every one of those is reported by the NewRegistry this is handed to.

func NumberValue

func NumberValue[T any](enc func(T) (string, error), dec func(string) (T, error)) Codec

NumberValue registers a type carried across the boundary as a number, spelled by the registrant's own text.

ferry.NumberValue(
    func(x big.Int) (string, error) { return x.String(), nil },
    func(s string) (big.Int, error) { ... })

The text is the plane's spelling of a number and is never parsed into a machine width by ferry, which is what makes a type wider than any Go number expressible at all. T is inferred from either function.

On Load the decode half is handed the plane's own number text, or a String's text where the plane has no numbers of its own. A null never reaches it.

Use NumberKey instead where the type also keys a map.

func StringValue

func StringValue[T any](enc func(T) (string, error), dec func(string) (T, error)) Codec

StringValue registers a type carried across the boundary as a string.

ferry.StringValue(
    func(u url.URL) (string, error) { return u.String(), nil },
    func(s string) (url.URL, error) {
        p, err := url.Parse(s)
        if err != nil {
            return url.URL{}, err
        }
        return *p, nil
    })

T is inferred from either function. A plane's number is not donated to a string, which is what keeps a quoted 8080 and an unquoted one distinguishable across a round trip, so the decode half sees a String and nothing else. A null never reaches it.

Check the codec against the zero value of T before writing one, because NewRegistry does: netip.Addr, netip.AddrPort and netip.Prefix all render their zero as "invalid IP" and cannot parse it back, so the obvious one-liner over String and Parse is refused for all three. Those are also the types that are better left to StringText, since the text pair they already carry is correct.

Use StringKey instead where the type also keys a map.

type Committer

type Committer interface {
	Commit(ctx context.Context) error
}

Committer is implemented by a Writer whose writes are not durable until the end of a successful walk: a staging file sink, a transactional KV.

Commit runs only when the walk succeeded; Releaser.Close, if the writer has one, runs either way. Neither takes a cause, because there is no failure to report to a driver, only a commit that does not happen.

It takes a context.Context where Close does not, because this is the actual I/O. It is separate from Releaser because the two do not co-occur: a transactional KV commits with nothing to release, and a lazy read side releases with nothing to commit.

type CompositeAddr

type CompositeAddr struct {
	// contains filtered or unexported fields
}

CompositeAddr is the address of a place whose children come from the value: a slice or a map.

Its members do not exist until there is a value, so Load discovers them through Enumerator.Children and a plane that cannot list reaches none of them. Which Go type produced it is not part of the address: a []string and a map[string]string are both a CompositeAddr, and a driver mints the Name or Index segments while the schema types the child they name.

func (CompositeAddr) Path

func (a CompositeAddr) Path() Path

Path is the composite's address with its kind dropped.

func (CompositeAddr) String

func (a CompositeAddr) String() string

String is the canonical rendering of the composite's address.

type Concurrent

type Concurrent interface {
	MaxConcurrent() int
}

Concurrent is implemented by a Reader whose open instance tolerates overlapping calls. It is the driver's half of the consent: without it a walk is serial whatever the caller asked for.

MaxConcurrent reports the instance's own tolerance - a pool size, a rate limit, whatever number the plane behind it can really take. A value of zero or less means the instance imposes no bound of its own, so the caller's number stands alone. Core never overlaps more than the smaller of the two.

It is discovered by assertion on the instance an OpenFunc returned, in the same idiom as Releaser and Committer, because tolerating overlap is a property of the open instance rather than of the Source value.

Declaring it is a promise about everything the instance reaches. Get, Probe and Children may all be called from several goroutines at once, and so may anything the instance closes over: a client, a cache, a key function, a caller-supplied callback. An instance that keeps mutable state per open guards it, or does not declare this.

Nothing on the write side asserts it. Dump does not fan out: a sink that wants overlap has it at Committer.Commit, where it batches already.

type Container

type Container interface {
	Member
	// contains filtered or unexported methods
}

Container is a Member that has children: a SectionAddr or a CompositeAddr.

It is what Prober.Probe is asked about and what Ensurer.Ensure writes at. A LeafAddr is not one, so asking whether a leaf is present, or writing a container-level answer at one, does not compile.

type Ensurer

type Ensurer interface {
	Ensure(ctx context.Context, addr Container, p Presence) error
}

Ensurer is implemented by a Writer whose plane can spell a container at the container's own address: one that is present and holds nothing, and one that is null.

Dump calls it where the value has nothing to say beneath a container: a nil pointer and an empty slice or map write PresenceNull, and a realised section that emitted no child write writes PresencePresent. It is never called with PresenceAbsent, because an address that is not written gets no call at all.

It is optional, and a plane with no spelling for a container implements nothing rather than storing something misleading. Dumping a value that needs one to a Writer without it is refused, naming the address and the plane.

type Enumerator

type Enumerator interface {
	Children(ctx context.Context, addr CompositeAddr) ([]Segment, error)
}

Enumerator is implemented by a Reader whose plane can list what is under a composite. It is how Load discovers the addresses that come from the value rather than from the type: a map's keys, a slice's length.

It is asked only about a CompositeAddr, so it cannot be asked to list a leaf or a section. A section's children come from the type and are never enumerated, which is the array-versus-slice difference seen from the driver's side: an array loads from a source that cannot enumerate and a slice does not. Loading a slice or a map from a non-enumerating source is an error naming the field and the source, never a silently empty one.

Children returns the segments the plane holds immediately under the address, each a NameSegment or an IndexSegment. The driver says how the plane spells its members and the schema types the child, so a driver never constructs an address. A Name under a sequence, or an Index under a mapping, is refused with the segment named.

The order is the plane's own, and a plane with no defined order documents the order it mints in.

type Error

type Error struct {
	// contains filtered or unexported fields
}

Error is one ferry failure: where it happened, when in the run, which class it belongs to, and what caused it.

if fe, ok := errors.AsType[*ferry.Error](err); ok {
    log.Println(fe.Address(), errors.Is(fe, ferry.ErrValue))
}

It has one accessor, Error.Address, and no exported fields, so there is nothing to switch on: the class is matched with errors.Is against ErrSchema, ErrMissing, ErrValue, ErrPlane, ErrPanic, ErrDriver or ErrReadOnly.

The address is the plane address wherever the position has one, and the Go field path only where it has none - a field with no tag never named an address, and that is the whole error. An error with no location, a close failure among them, returns the zero Path.

The cause stays in the chain, so errors.Is against a driver's own sentinel or against strconv.ErrRange still answers.

Message text is not API. Match on the sentinels and on the address. ferry's own text never repeats a value the plane supplied, because ferry cannot know which addresses hold secrets; what it names instead is structure, such as the observed kind, the target type, or an array's length.

func (*Error) Address

func (e *Error) Address() Path

Address is where the failure happened: the plane address, or the Go field path where the position names no address at all. An error with no location returns the zero Path.

It is what to match on. The printed line may open with the plane's own name for the address instead, because a driver implementing PlaneNamer supplies one, and this is unaffected by that.

func (*Error) Error

func (e *Error) Error() string

Error renders the failure on one line, prefixed with "ferry: ". The text is not API; match on the sentinels and on Error.Address.

func (*Error) Format

func (e *Error) Format(f fmt.State, verb rune)

Format renders %v as the one line, %+v as the full report, %s as the one line and %q as it quoted.

func (*Error) Is

func (e *Error) Is(target error) bool

Is matches the class sentinel and the provenance marker, which is what makes errors.Is the whole of the matching mechanism.

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap returns the cause, so a driver's own error and a decode failure's strconv sentinel both stay matchable through ferry's wrapper.

type ExtTable

type ExtTable struct {
	// contains filtered or unexported fields
}

ExtTable is every declared extension word one compiled type's tags carried, keyed by tag key and then by the address the word sits at.

ExtensionTable returns one, and AddressSet.Extension is the same view a driver reads at its own Bind. The zero value holds nothing and was declared nothing, which is what a type compiled against a registry that declares nothing produces.

func ExtensionTable

func ExtensionTable[T any](opts ...Option) (ExtTable, error)

ExtensionTable compiles T and hands back every declared extension word its tags carried, without a plane and without a driver.

table, err := ferry.ExtensionTable[Config](ferry.WithRegistry(registry))
view, _ := table.Extension("docs")
for addr, words := range view {
    fmt.Println(addr, words["desc"])
}

It is the door for a consumer that never meets a plane, a documentation generator being the plain case. A driver needs none of it: the same view rides the AddressSet its own Bind is handed, which is AddressSet.Extension.

It runs exactly the compiler Load and Dump run and takes the same Option values, so a type it accepts is a type they accept and a tag it refuses is a tag they refuse. It compiles the schema and discards it, so it retains no resolution.

The table is empty where the registry this call resolves against declares no tag key, which is every call that names no registry of its own, and it reports every key that registry did declare as declared whether or not a field carried one.

func (ExtTable) Extension

func (t ExtTable) Extension(key string) (map[Path]map[string]string, bool)

Extension is one tag key's address-keyed view: for each address whose field carried that key, the words it carried and the text of each.

view, declared := table.Extension("yamlext")
for addr, words := range view {
    nodeTags[addr] = words["node"]
}

A word declared without a value reads as the empty string, and asking whether the word is there is the two-result map index.

The second result reports the key having been declared on the registry this schema was compiled against, and it is the only way to tell a declaration nobody used from one nobody made. A declared key no field carried yields an empty view and true; a key that was never declared yields an empty view and false. Neither is an error, and a consumer for whom the difference matters is the one that must look: a forgotten declaration otherwise reads as a struct carrying no words.

The view is freshly allocated and the caller's to keep, so writing to it changes nothing about the compiled schema it came from.

type KeyCodec

type KeyCodec struct {
	// contains filtered or unexported fields
}

KeyCodec is a registration whose kind may address a map key, which is String and Number and nothing else.

It is a Codec and is handed to NewRegistry like any other. What it adds is KeyCodec.AsMapKey, and a constructor that does not return one is a registration no map can be keyed by, at compile time rather than at registration.

func DurationLike

func DurationLike[T ~int64]() KeyCodec

DurationLike registers a named type over int64 with time.Duration's own representation, so it dumps "30s" rather than a nanosecond count.

type PollInterval time.Duration

reg := ferry.NewRegistry(ferry.DurationLike[PollInterval]())

It is the remedy for a sharp edge: a named type over time.Duration is a distinct reflect.Type, so it misses the type ferry pins and falls through to kind int64. ferry cannot close that by matching on the underlying type instead, because that would capture every ordinary `type Port int` too.

It is the other constructor with no value argument to infer T from, so T is named. It is a KeyCodec because time.Duration's text is injective over the type, which is why core keys a map by one; add KeyCodec.AsMapKey where the named type keys a map too.

func NumberKey

func NumberKey[T any](enc func(T) (string, error), dec func(string) (T, error)) KeyCodec

NumberKey registers a type carried as a number that may also key a map.

ferry.NumberKey(
    func(v Version) (string, error) { return strconv.Itoa(int(v)), nil },
    func(s string) (Version, error) { ... }).AsMapKey()

It is NumberValue with KeyCodec.AsMapKey available, and the two are otherwise identical: a registration built here and handed to NewRegistry without that call keys nothing.

func NumberText

func NumberText[T any, PT TextPointer[T]]() KeyCodec

NumberText registers a type that already declares its own text form and its own inverse, carried across the boundary as a number.

ferry.NumberText[big.Int]()

It is StringText at KindNumber, and the kind is what it is for. big.Int's text is a run of digits, so unregistered it lands as string("1099511627776") and does not load from a YAML plane that reports a number; declaring a number loads from both, because a plane's string is donated to the declared kind on the way in.

It refuses the value-receiver UnmarshalText StringText describes.

func StringKey

func StringKey[T any](enc func(T) (string, error), dec func(string) (T, error)) KeyCodec

StringKey registers a type carried as a string that may also key a map.

ferry.StringKey(
    func(r Region) (string, error) { return string(r), nil },
    func(s string) (Region, error) { return Region(s), nil }).AsMapKey()

It is StringValue with KeyCodec.AsMapKey available, and the two are otherwise identical: a registration built here and handed to NewRegistry without that call keys nothing.

func StringText

func StringText[T any, PT TextPointer[T]]() KeyCodec

StringText registers a type that already declares its own text form and its own inverse, carried across the boundary as a string.

ferry.StringText[netip.Addr]()

It takes no functions, because *T supplies both halves. Its purpose is declaring the boundary kind and, through KeyCodec.AsMapKey, declaring the text injective: any type carrying a text pair is already claimed at KindString with nothing registered, and what a registration adds is those two declarations.

It is one of the two constructors that name their type argument, because there is no value argument to infer it from. PT is inferred from T, so a call site writes one type argument and never two.

It is refused where T declares UnmarshalText on a value receiver: that method is in *T's method set and satisfies the constraint, and it decodes into a copy and leaves the field unchanged, so it is not a decode half a registration can use. The refusal is reported by the NewRegistry this is handed to.

func (KeyCodec) AsMapKey

func (k KeyCodec) AsMapKey() Codec

AsMapKey declares this codec's text injective over its type under Go's ==, which is what a map key needs.

ferry.StringText[netip.Addr]().AsMapKey()

It is a claim ferry cannot check, and it is opt-in because the failure it prevents is silent: two keys rendering to one text are one address, so one entry is lost with no error anywhere and which one survives is map iteration order. Using a registered type as a map key without it is a schema compile error naming this method.

The registration it returns is the one that carries the claim, and the receiver is unchanged. Hand the result to NewRegistry; calling this and discarding what it gives back registers a codec that keys nothing.

ferrytest.Injective discharges the claim over the values a registrant cares about.

type KeyExtension

type KeyExtension struct {
	// TagKey is the struct tag key this extension owns. It may not be the key
	// ferry reads, and it is a bare word: no space, quote, colon, comma, dot or
	// equals sign.
	TagKey string

	// Words is the whole vocabulary of the key, and a word outside it is refused
	// where a tag carries it, with the same near-miss suggestion ferry gives for
	// its own.
	Words []Word
}

KeyExtension is one library's declaration: the struct tag key it owns, and every word ferry may read under it.

func Extension() ferry.KeyExtension {
    return ferry.KeyExtension{
        TagKey: "yamlext",
        Words:  []ferry.Word{{Name: "node", TakesValue: true}},
    }
}

Hand it to WithTagKeys, which is the only thing that takes one.

The key is yours and never ferry's: a field then carries both tags, and each is read with its own vocabulary.

Wait time.Duration `ferry:"wait,required" yamlext:"node=!mycompany:duration"`

type KeyFunc

type KeyFunc func(addr Path) (string, error)

KeyFunc maps a ferry address to a key in one plane's own key space: the join an environment driver spells with _, the dotted path a flat KV uses, the hyphen join that spells an HTTP header name.

A driver supplies one to NewKeys and gets one back from Keys.Open, so the shape is the same at both ends: an address in, a checked plane key out. It takes the address with its kind dropped, because a plane key is a function of the segments and never of the kind: read one off a typed address with Member.Path.

A KeyFunc answers legality and never injectivity. Legality is what it returns an error for: whether the plane can name this address at all. An empty segment has no environment variable name, and no transformation rescues it. Whether the transformation collapses two addresses onto one key is not a question one call can answer, because one call cannot see a set; NewKeys answers that.

A KeyFunc is expected to transform segment text rather than to reject it. An environment variable name may not contain a hyphen, so a key function that only validates refuses feature-flags, which is an ordinary thing to write in a config struct; one that maps the hyphen to _ accepts it and is no less safe, because the injectivity check is what catches a transformation that merges two addresses.

type Keys

type Keys struct {
	// contains filtered or unexported fields
}

Keys is a driver's plane keys for one compiled schema, computed once and checked once, before any I/O.

A driver builds one with NewKeys inside Source.Bind or Sink.Bind, where it holds the whole address set and has not yet touched its plane, and calls Keys.Open once per load or per dump.

The table is written before the value is returned and never again, so reading it takes no lock and one binding is safe to use from many goroutines. The addresses a value mints live in the open instead.

Nothing obliges a driver to route its lookups through this type, and a driver that builds its own map[Path]string gets neither check and no diagnostic saying so, because core is not in the call.

func NewKeys

func NewKeys(a *AddressSet, name string, f KeyFunc) (*Keys, error)

NewKeys computes a driver's plane keys for one schema and checks them. It is the whole of what a flattening driver has to do with the address set it was bound to.

func (s Source) Bind(addrs *ferry.AddressSet) (ferry.OpenFunc, error) {
    keys, err := ferry.NewKeys(addrs, "env", s.key)
    if err != nil {
        return nil, err
    }
    ...
}

It takes the address set, the driver's own short name for its diagnostics, and its KeyFunc. It returns a binding that serves those keys from a precomputed table, or an error naming every address the plane cannot name and every pair the key function collapses onto one key. Return that error from Bind unchanged; core supplies the rest.

Both refusals land before any I/O, which is what lets a plane-to-plane transfer be refused after zero backend calls rather than after reading the whole source. They are collected and sorted rather than reported one at a time, and each names both offending addresses:

ferry: 2 errors:
  /DB_HOST: env gives this and /DB/HOST the same name, "DB_HOST", ...
  /feature_flags: env gives this and /feature-flags the same name, ...

A tree driver calls none of this. It walks the segments, builds no plane key at all, and so carries no injectivity obligation.

func (*Keys) Open

func (k *Keys) Open() KeyFunc

Open starts one load or one dump over this table and hands back the KeyFunc for it. Call it from the OpenFunc or OpenWriterFunc, once per load or per dump.

An address the type determined is served from the precomputed table. An address a value mints - a map key, a sequence index - is minted on demand and checked as it is minted, against the table and against everything this open has already minted, before the write it belongs to. So a legitimate map key is answered rather than refused, which is why core hands back a function and not a map: a map invites a driver to treat a miss as an error.

Each call gets a fresh minted set, and nothing an open mints outlives it. Two dumps through one binding are not required to be mutually injective, only each within itself.

The returned function belongs to the open and is not safe for concurrent use. The Keys it came from is, because the table behind it never changes.

func (*Keys) PlaneName

func (k *Keys) PlaneName(addr Path) (string, bool)

PlaneName is this plane's own key for an address, for a report to print in place of ferry's own rendering of it. A driver holding a Keys satisfies PlaneNamer by forwarding one method to this one.

An address the type determined is answered from the precomputed table, and any other is computed on the spot and not recorded, so calling this affects no open and is safe from any goroutine. An address this plane cannot name is a false rather than an error, because a report is composed after the failure it is about and has nowhere left to put a second one.

type LeafAddr

type LeafAddr struct {
	// contains filtered or unexported fields
}

LeafAddr is the address of a place a Value can be: what Reader.Get reads and what Writer.Set writes.

It is comparable, so it is a map key with no encoding step, and a driver that classified the address set at Source.Bind can serve a read from a table it computed before any I/O.

Read it with LeafAddr.Path, which is what a key function walks, and with LeafAddr.Wants, which is the kind of value the schema wants here.

func (LeafAddr) Path

func (a LeafAddr) Path() Path

Path is the address with its kind dropped, which is what a KeyFunc walks to build a plane key. The kind is not part of a plane key, because a key is a function of the segments.

func (LeafAddr) String

func (a LeafAddr) String() string

String is the canonical rendering: /db/host, /tags#0. It identifies the address and it is not a plane key, for the reason Path.String gives.

func (LeafAddr) Wants

func (a LeafAddr) Wants() VKind

Wants is the kind of value the schema wants at this address.

It is what a plane carrying no type information of its own reads to decide a spelling exactly where the schema asks for one: a source that renders text answers String everywhere and a typed value only where this says so.

It is what the schema wants and not the whole of what it takes. A leaf accepts a KindString beside its own kind whatever this returns, so text is never the wrong answer. In the other direction it decides nothing: a Writer.Set answers with what its plane holds, which is the Value in hand, and the two can legitimately differ - a nil pointer leaf writes a Null at an address whose schema kind is the pointee's.

type LeafRedirect

type LeafRedirect struct {
	// Target is where the value lives.
	Target LeafAddr
}

LeafRedirect is what a Reader.Get returns when the plane holds a link at this address and the value lives at another one.

It is returned as an error and it is not a failure. It is a control answer, in the shape fs.SkipDir has, so a value stays the six kinds it always was and no caller has to handle a seventh that means "look over there". Match it with errors.As:

func (r reader) Get(ctx context.Context, addr ferry.LeafAddr) (ferry.Value, error) {
    if to, linked := r.linkAt(addr); linked {
        return ferry.Value{}, &ferry.LeafRedirect{Target: to}
    }
    ...
}

Report one hop. The chain is followed for you, the addresses already visited are kept, and a cycle is refused naming the address it closes through.

The target is an address you were handed, because nothing outside ferry builds one, so a link whose target this schema does not name cannot be reported and stays yours to resolve or to refuse.

func (*LeafRedirect) Error

func (r *LeafRedirect) Error() string

Error names the address the value lives at. It reads as a statement rather than as a failure, because it is one.

type Member

type Member interface {
	// Path is the address with its kind dropped, which is what a key function
	// walks to build a plane key.
	Path() Path
	// String is the canonical rendering of the address.
	String() string
	// contains filtered or unexported methods
}

Member is one address a compiled schema determines: a LeafAddr, a SectionAddr or a CompositeAddr, and nothing else.

It is what AddressSet.Seq yields and what AddressSet.Has answers about. A driver classifies once, at Source.Bind, with one range and one type switch on the cold path:

for m := range addrs.Seq() {
    switch a := m.(type) {
    case ferry.LeafAddr:      d.keys[a] = key(a.Path())
    case ferry.SectionAddr:   d.prefixes[a] = prefix(a.Path())
    case ferry.CompositeAddr: d.prefixes[a] = prefix(a.Path())
    }
}

Every address ferry hands you was minted by the schema compiler and is one of those three, so the type switch above covers every address you will be given. Core refuses anything else: a value of your own satisfying this interface is in no address set, and asking a set whether it holds one answers false.

type OpenFunc

type OpenFunc func(ctx context.Context) (Reader, error)

OpenFunc opens a Reader over the addresses a Source was bound to. It is called once per load, and may be called many times against one Bind.

It may be called from many goroutines at once, because a caller may hold what Source.Bind returned and load through it concurrently. A driver that precomputes at Bind and only reads that afterwards already satisfies this; one that writes to what it closed over does not.

Whether the driver fetches the whole plane here in one round trip or fetches nothing until the first Get is the driver's own choice, and core has no opinion: Bind already handed over the whole address set, so both are expressible with no extra interface.

type OpenWriterFunc

type OpenWriterFunc func(ctx context.Context) (Writer, error)

OpenWriterFunc opens a Writer over the addresses a Sink was bound to. It is called once per dump, and may be called many times against one Bind.

It may be called from many goroutines at once, on the same terms as OpenFunc and for the same reason: a caller may hold what Sink.Bind returned and dump through it concurrently.

It is where a read-only refusal lands, wrapping ErrReadOnly: a KV with no write ACL, or a file sink over an unwritable directory, fails here after zero writes rather than half way through a walk over the user's struct.

type Option

type Option interface {
	// contains filtered or unexported methods
}

Option is a setting a caller hands to Load, LoadOver, Dump, Compile, Bind or BindSink. There are four: TagKey, WithRegistry, MaxConcurrency and RootRequired.

The set is closed, because the interface's one method is unexported. A library built on ferry can therefore change where ferry reads its annotation from, and cannot change what the annotation means.

Every one of them is refused when supplied twice in one call.

TagKey, WithRegistry and RootRequired change what a type compiles to, so all three are part of the key a compiled schema is cached under. MaxConcurrency changes only how a load is run, so it is not in that key and two loads of one type under two budgets still compile once.

func MaxConcurrency

func MaxConcurrency(n int) Option

MaxConcurrency allows a load to overlap up to n calls into the plane, where today it makes exactly one at a time.

cfg, err := ferry.Load[Config](ctx, consul.New(client), ferry.MaxConcurrency(8))

It is a ceiling and never a target. Overlap happens only where the driver's open instance also declared it tolerates overlap, by implementing Concurrent, and never past the smaller of the two numbers. A source that does not implement it - a file, a process environment - is walked serially whatever n says, so setting this changes nothing about a driver that did not offer it.

The same n reaches the driver behind its own open, where it reads it with ConcurrencyBudget, so a driver that batches its own requests sizes the batch inside the budget instead of beside it. One number is spent once, however many layers spend it.

n must be at least 1, and 1 is legal and means serial. It changes only how a load is run and never what a type compiles to, so it is not part of the schema cache key and two loads under two budgets compile once. It is refused when supplied twice.

Reading it back out is not the point of it, and there are three sharp edges.

A concurrent load reports exactly what a serial one reports: the members of a container are combined in the order the container lists them and never in the order they finished, so the destination and the failure are the same value and the same text either way.

Fanout covers the members a type names - a struct's fields, an array's elements, what is under a pointer. The members a plane names, under a slice or a map field, are walked in order. So a wide struct of leaves overlaps and a five-hundred-key map does not.

A panic ferry itself raises inside an overlapped member ends the process rather than unwinding into the caller, because it is raised on a goroutine the caller does not own. A panic out of your own codec is unaffected: it is recovered at the call and arrives in the report at the address that produced it, whether or not the walk was overlapped.

func TagKey

func TagKey(key string) Option

TagKey names the struct tag key ferry reads, which defaults to "ferry".

cfg, err := ferry.Load[Config](ctx, src, ferry.TagKey("mylib"))

It exists for a library built on ferry, whose users should be writing that library's tag rather than ferry's.

It names where to look and never what the content means. Under whatever key it is told to read, ferry reads ferry's own grammar and holds it to ferry's own strictness, so mylib:"host,retry=3" is still a schema compile error. That is the sharp edge: pointing ferry at a key another mapper already uses does not make that mapper's options legal, and json:"name,omitempty" refuses.

It applies to every struct reached by the call it is handed to, not only to the top-level one, and it is refused when supplied twice: two keys on one field would be two address sets with nothing to choose between them.

A key that could never be written into a struct tag at all - one holding a space, a quote or a colon - is refused here, at the call the Option was given to, rather than at schema compile.

Example

ExampleTagKey reads the annotation from another struct tag key.

It names where to look and never what the content means: the grammar under `mylib` is still ferry's, held to ferry's strictness. It applies to every struct the call reaches, not only the top-level one.

package main

import (
	"context"
	"fmt"

	"github.com/onhotpath/ferry"
	"github.com/onhotpath/ferry/ferrytest"
)

// Service carries its annotation under the tag key `mylib` rather than `ferry`.
type Service struct {
	Name    string `mylib:"service,required"`
	Timeout int    `mylib:"timeout,default=30"`
}

func main() {
	src := ferrytest.Static(map[ferry.Path]ferry.Value{
		ferry.At("service"): ferry.String("checkout"),
	})

	cfg, err := ferry.Load[Service](context.Background(), src, ferry.TagKey("mylib"))
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Printf("%+v\n", cfg)
}
Output:
{Name:checkout Timeout:30}

func WithRegistry

func WithRegistry(reg *Registry) Option

WithRegistry names the codec registry this call resolves types against, instead of the one core ships.

reg := ferry.NewRegistry(ferry.StringText[netip.Addr]())

cfg, err := ferry.Load[Config](ctx, src, ferry.WithRegistry(reg))

It is what lets two tests in one process want different codecs for one type, and what a library uses to keep its own codecs out of its consumer's calls.

The registry it names is complete before this call sees it, because NewRegistry takes the whole codec set and there are no mutators, so naming one here changes nothing about it and there is no ordering rule to keep.

It is refused when supplied twice, and a nil registry is refused rather than read as the default: core's own type set with no codec over it is spelled ferry.NewRegistry(), and omitting the Option is how it is asked for.

Example

ExampleWithRegistry resolves one call against a registry of the caller's own.

netip.Addr carries a text pair, so ferry already knows how to store one. What it cannot know is that the text is injective, which is what a map key needs, so keying a map by it takes a registration declaring ferry.KeyCodec.AsMapKey.

package main

import (
	"errors"
	"fmt"
	"net/netip"

	"github.com/onhotpath/ferry"
)

// Peers keys a map by a type ferry does not own, which needs a registration.
type Peers struct {
	Names map[netip.Addr]string `ferry:"names"`
}

func main() {
	fmt.Println(errors.Is(ferry.Compile[Peers](), ferry.ErrSchema))

	reg := ferry.MustRegistry(ferry.StringText[netip.Addr]().AsMapKey())

	fmt.Println(ferry.Compile[Peers](ferry.WithRegistry(reg)))
}
Output:
true
<nil>

type ParseFunc

type ParseFunc[C, T any] func(c C) (T, error)

ParseFunc is the reading half of a Spelling as a function: a carrier in, a payload out.

type Path

type Path struct {
	// contains filtered or unexported fields
}

Path is the address of a place a plane can be asked for a Value, or handed one: an ordered sequence of segments, each carrying a SegmentKind and a text.

ferry.At("db", "host")        // /db/host
ferry.At("tags").Elem(0)      // /tags#0

Build one with At, Path.At and Path.Elem; read it with Path.Segments. A Path is comparable, so it is a map key and a set element with no encoding step, and identity is ==.

Path.String is a canonical rendering that identifies the address, and it is not a plane key: no driver may write it into a plane. Core never joins segments into a key, because a separator is plane knowledge. An environment driver joins with _, a YAML driver walks the segments as a tree, and neither spelling is core's business.

Sort with Path.Compare and not by the rendering. As text, twelve indices give 0 1 10 11 2 3; segment-wise they give 0 1 2 through 11, which is the order a human diffing a dumped file expects, and the order ferry enumerates addresses in.

The zero Path has no segments, and it is the root address: where the type a call names resolves to a single value rather than to a struct, that value sits there. A plane names the root by its own rule or refuses it at Bind, so a driver's key function asks Path.IsRoot before it walks the segments.

func At

func At(names ...string) Path

At builds an address out of Name segments, which is what a struct field, an object member or a map key contributes.

It is the ordinary way to write a literal address: At("db", "host") is /db/host. With no arguments it is the root address, which is where a type resolving to a single value sits.

Example

ExampleAt builds an address and walks its segments.

The rendering identifies the address. It is not a plane key: how segments are joined, or walked as a tree, is the driver's business.

package main

import (
	"fmt"

	"github.com/onhotpath/ferry"
)

func main() {
	addr := ferry.At("servers").Elem(1).At("host")

	fmt.Println(addr)

	for seg := range addr.Segments() {
		fmt.Println(seg.Kind(), seg.Text())
	}
}
Output:
/servers#1/host
Name servers
Index 1
Name host

func (Path) At

func (p Path) At(names ...string) Path

At extends the address with Name segments, leaving the receiver untouched.

func (Path) Compare

func (p Path) Compare(q Path) int

Compare orders two addresses segment-wise, comparing Name segments by exact bytes and Index segments numerically, and orders a prefix before what extends it. It reports -1, 0 or +1 and is a total order, so slices.SortFunc takes it directly as Path.Compare.

It is not the order of the renderings, and the difference is the point: sorted as text, twelve indices give 0 1 10 11 2 3, and /a-x sorts before /a/b because a separator byte sorts against ordinary text.

func (Path) Elem

func (p Path) Elem(i uint) Path

Elem extends the address with an Index segment, a position in a sequence, leaving the receiver untouched. The position is unsigned because a negative one has no meaning, so the constraint is in the type rather than in a check the caller can trip over.

func (Path) IsRoot

func (p Path) IsRoot() bool

IsRoot reports whether this is the root address, the one with no segments.

A driver's key function asks it first. The root has no segment to build a key out of, so a plane either names it by a rule of its own - an option holding the key to use, a file's whole document - or refuses it, and refusing it at Bind is where a caller can still do something about it.

func (Path) Segments

func (p Path) Segments() iter.Seq[Segment]

Segments enumerates the address left to right. It is what a driver's key function walks to build a plane key.

for seg := range addr.Segments() {
    if seg.Kind() == ferry.Index { ... }
}

func (Path) String

func (p Path) String() string

String is the canonical rendering: /db/host for two Name segments, /tags#0 for a Name segment followed by an Index. Two addresses render alike exactly when they are equal, so it identifies the address.

It is not a plane key, no driver may write it into a plane as one, and sorting it is not this type's ordering: see Path.Compare.

type PlaneNamer

type PlaneNamer interface {
	PlaneName(addr Path) (string, bool)
}

PlaneNamer is implemented by a Reader or a Writer whose plane has a name of its own for an address: the environment variable it is read from, the store key it is written to, the parameter it arrived in.

It is what a report opens with. A failure at an address is printed as "ferry: DB_HOST: ..." where the plane names it DB_HOST, and as "ferry: /db/host: ..." where nothing does, so the line names the thing the person reading it can go and change. Error.Address is unmoved and still returns the address, so what to match on does not change.

PlaneName is called once per located failure, after the run has finished and only where the run failed. Return false for an address this plane has no name for, and ferry's own rendering of it stands. There is no error return: an address the plane cannot name is a false, and a report is not the place to find that out.

One obligation, and it is the reason this is a separate interface rather than something core computes. The name must be a function of the address and of the driver's own configuration, and of nothing the plane holds. ferry's own message text never repeats a value the plane supplied, and a name derived from one would put it back in the line.

It is optional, in the same idiom as Releaser. A driver that flattens gets it from the Keys it already built, and a driver that walks segments as a tree renders the segments itself.

type Preparer

type Preparer interface {
	Prepare(ctx context.Context, addrs []Path) error
}

Preparer is implemented by a Writer that wants to see the addresses a dump determined from the value before the dump writes any of them.

The set is the addresses that come from the value and not from the type: a map key, a sequence index. Everything the type determined arrived at Sink.Bind and is not repeated here. It is sorted, it is yours to keep, and a value holding no slice and no map produces an empty one rather than no call.

Prepare runs once per dump, after every value has been encoded and before the first write. Returning nil lets the writes proceed. Refusing stops the dump where it stands, so nothing is written at all - which is what it is for: a plane that renders two of these addresses to one key loses one of them, and without this it can only say so from inside the write that carried the second, by which time the writes before it have landed.

Name the offending addresses with ErrorAt, as a key function does, so that each refusal is reported against the address it belongs to.

It is optional, and a Writer without one is asked nothing and refused nothing. It is also not asked of a Committer, which already leaves the plane untouched when a dump fails by not committing, and which is written to as the walk runs - so there is no moment at which the whole set is known and the plane still holds nothing.

type Presence

type Presence uint8

Presence is what a plane holds at a container address, and the set is closed at four: PresenceAbsent, PresencePresent, PresenceNull and PresenceElsewhere.

It is the container-side counterpart of VKind. A container is read one child at a time and has no group value of its own, so the only thing there is to ask at its own address is whether it is there.

const (
	// PresenceAbsent means the plane does not have this address at all. It is
	// presence zero, so the zero [SectionInfo] is absence.
	PresenceAbsent Presence = iota

	// PresencePresent means the plane has this address and holds a container
	// there, which may be an empty one.
	PresencePresent

	// PresenceNull means the plane has this address and holds its own null
	// there. Only a plane whose type system contains a null can produce it.
	PresenceNull

	// PresenceElsewhere means the plane holds a link here, and what this
	// address names lives at the one [SectionInfo.Redirect] hands back. It is
	// not an answer about this address's own contents, and reading it as one is
	// the mistake this presence exists to make impossible.
	PresenceElsewhere
)

func (Presence) String

func (p Presence) String() string

String names the presence in lower case: absent, present, null, elsewhere.

type Prober

type Prober interface {
	Probe(ctx context.Context, addr Container) (SectionInfo, error)
}

Prober is implemented by a Reader whose plane can say whether a container is there. It answers about a Container, which is a SectionAddr or a CompositeAddr and never a leaf.

Return SectionPresent, SectionAbsent or SectionNull. Absence means the plane does not have the address at all, a null means it has it and holds its own null there, and present means it has it and holds a container, which may be an empty one.

A plane with links has a fourth answer, SectionAt, which says the address names a place that lives somewhere else. Report one hop; the chain, the addresses already visited and the refusal of a cycle are handled for you.

It is optional, in the same idiom as Releaser, because a plane that cannot list often cannot answer this either. A source implementing neither this nor Enumerator loads the leaves the type determines and nothing else: a nil pointer stays nil where the plane is silent beneath it, and a slice or a map is a refusal naming the field and the source.

type Reader

type Reader interface {
	Get(ctx context.Context, addr LeafAddr) (Value, error)
}

Reader is an open plane, answering one leaf at a time.

It is asked only about a LeafAddr, which is an address a value can be at. A container's own address is a SectionAddr or a CompositeAddr, so asking this method about one does not compile, and a plane that happens to hold something under a container's own name can no longer have that something mistaken for the container's value.

Absence is a kind of the value rather than a second return value: an address the plane does not have is reported as the zero Value, whose Value.Kind is KindAbsent. There is no sentinel error for it, so a driver's own "not found" cannot be confused with a real failure.

One rule binds an implementation: a non-nil error must reach the caller as an error and never as an absent value, which is the defect that turns a parse failure into a config silently loaded from nothing. A plane holding a container where the schema says leaf is a mismatch, and a driver refuses it with the address and what the plane holds rather than answering absence.

A Reader may also implement Prober, Enumerator, Releaser and PlaneNamer. All four are discovered by assertion and none is required.

type Registration

type Registration interface {
	// contains filtered or unexported methods
}

Registration is one item NewRegistry is built from, and the set is closed at two: a Codec, and the tag key declaration WithTagKeys returns.

It exists so that one constructor takes the whole of what a registry is, and a declaration is not spelled as a codec over no type. Nothing outside this package implements it, for the reason Codec gives.

func WithTagKeys

func WithTagKeys(exts ...KeyExtension) Registration

WithTagKeys declares foreign struct tag keys for a registry to read beside ferry's own, and is handed to NewRegistry like a codec.

var Registry = ferry.MustRegistry(
    ferry.NumberText[big.Int](),
    ferry.WithTagKeys(yaml.Extension()),
)

A declared key is parsed with that extension's vocabulary and handed back inert: core validates the words, puts them in an address-keyed table, and never acts on them. Read the table at a driver's own Bind with AddressSet.Extension, or out of band with ExtensionTable.

An undeclared key is another library's business and is never claimed, so a json or a validate tag on the same field is untouched.

A word only reaches the table where the field named an address. A field marked "-" and a field ferry never reads carry their extension words nowhere, and so does a field under a slice or a map, which names an address shape rather than an address: its words are still held to the declaration, and there is no address for them to sit at.

It refuses four things, at the NewRegistry that was given it rather than at any later compile: a key that is not a bare word, a key ferry itself reads, a key declared twice, and a word that is empty or is spelled with a comma or an equals sign in it. Each is reported as what NewRegistry reports, for the reason given there.

Example

ExampleWithTagKeys declares a foreign struct tag key and reads what it carried, address by address.

ferry's own vocabulary is unchanged: the words live under a key the declaring library owns, they are validated against the declaration, and core acts on none of them. A driver reads the same view at its own Bind, through ferry.AddressSet.Extension, so nothing is plumbed through the caller.

package main

import (
	"fmt"

	"github.com/onhotpath/ferry"
)

// Documented carries a declared extension key beside ferry's own. The docs tag
// is another library's vocabulary, and ferry reads it because it was told to.
type Documented struct {
	Host string `ferry:"host,required" docs:"desc=where the service lives"`
	Port int    `ferry:"port,default=8080" docs:"desc=the port it listens on"`
}

func main() {
	reg := ferry.MustRegistry(ferry.WithTagKeys(ferry.KeyExtension{
		TagKey: "docs",
		Words:  []ferry.Word{{Name: "desc", TakesValue: true}},
	}))

	table, err := ferry.ExtensionTable[Documented](ferry.WithRegistry(reg))
	if err != nil {
		fmt.Println(err)

		return
	}

	view, _ := table.Extension("docs")
	for _, addr := range []ferry.Path{ferry.At("host"), ferry.At("port")} {
		fmt.Println(addr, "-", view[addr]["desc"])
	}
}
Output:
/host - where the service lives
/port - the port it listens on

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry is the set of codecs one program registers for types ferry does not own, over the set core already owns.

Build one with NewRegistry or MustRegistry, name it for a call with WithRegistry, and keep it. It is complete when it is built and there is nothing to add afterwards, so a package-level var or a per-test local is the whole idiom.

A registry is a value to keep, because the compiled-schema cache hangs off it. Nothing is ever evicted from that cache, so a registry that stays alive keeps every schema ever compiled against it alive too, and a fresh registry per call means a full schema compile per call. Build one per program, or one per test.

A nil *Registry reads as one holding no codec of its own, so Registry.Types can be asked about a program that registered nothing.

func MustRegistry

func MustRegistry(items ...Registration) *Registry

MustRegistry is NewRegistry for a registry that is written once, and it panics where that one would return an error.

var Registry = ferry.MustRegistry(
    ferry.NumberText[big.Int](),
    ferry.StringText[netip.Addr]().AsMapKey(),
)

It is here for the declaration a var has no error to check on, in regexp.MustCompile's family: a codec set is source rather than input, so a refusal is a mistake in the program rather than a condition a running one meets, and the alternative is an error return on a line nobody checks.

What it panics with is exactly what NewRegistry returns, an *Error of ErrSchema's class, so a caller who recovers one reads the ordinary report. Use NewRegistry anywhere there is somewhere to put an error, a test with a *testing.T in hand being the plain case.

func NewRegistry

func NewRegistry(items ...Registration) (*Registry, error)

NewRegistry builds the registry a call resolves types against: core's own type set, plus one codec per Codec handed to it, plus whatever foreign struct tag keys WithTagKeys declares.

registry, err := ferry.NewRegistry(
    ferry.NumberText[big.Int](),
    ferry.StringText[netip.Addr]().AsMapKey(),
    ferry.WithTagKeys(yaml.Extension()),
)

cfg, err := ferry.Load[Config](ctx, src, ferry.WithRegistry(registry))

It is the whole of the registry API. A registry is complete when it is built and there are no mutators, so there is no window in which one is reachable and incomplete, and no ordering rule to keep between building it and using it.

Core's own set is always underneath, so registering one type never costs a caller string, int, bool, time.Duration or anything else ferry already carries. Passing no codec at all is exactly the set core ships, which is what a call with no WithRegistry resolves against.

Use MustRegistry where the registry is a package-level var, which is the common case: there is no error to check on a var declaration and a registry that will not build is a program that cannot start.

It refuses five things about a codec. A nil one. A pointer type, because pointer indirection is structural and a codec for one would lose the null a nil pointer writes. A type core owns, whose representation is pinned and not replaceable, every predeclared type included: define a named type over it and register that. A second codec for a type another codec in the same call already claimed, since a registration claims its type unconditionally and there is no decline. And a codec that is not total over the zero value of its type, which is checked by running it.

That last check catches one class of wrong codec out of three. A lossy codec and a constant codec both pass it, and the way to discharge those is a proof through ferrytest.

What it refuses about a declared tag key is listed on WithTagKeys, and is refused here for the same reason and in the same words. A refusal a constructor already found - a nil codec half, a text pair that cannot decode, a null policy over something that cannot carry one - is reported here too, because a constructor has a registration to return and no error.

The error is an *Error of ErrSchema's class at the register moment, so it reads as every other ferry refusal reads, and a list holding several bad items reports all of them. Nothing is built on a refusal: the registry is nil.

func (*Registry) Types

func (r *Registry) Types() []reflect.Type

Types is every type this registry holds a codec for, sorted.

It exists so that a completeness check can join a list of proofs against the types that were registered, and tell a registrant who added a codec and no proof. The result is freshly allocated and the caller's to keep, and a nil registry holds nothing.

type Releaser

type Releaser interface {
	Close() error
}

Releaser is io.Closer, and a Reader or Writer implements it when it holds a resource. It is not a name ferry invents: a driver wrapping a file or a connection satisfies it already.

Close takes no context, because cleanup that can be cancelled is how the temp file leaks. It always runs, whether the walk succeeded or failed, and closed-without-Committer.Commit is the abort signal, so no driver is ever told that it failed.

It is optional so that a driver with nothing to release implements nothing, rather than writing a `return nil` that reads exactly like a rollback somebody forgot.

type RenderFunc

type RenderFunc[T, C any] func(v T) (C, error)

RenderFunc is the writing half of a Spelling as a function: a payload in, a carrier out.

type SectionAddr

type SectionAddr struct {
	// contains filtered or unexported fields
}

SectionAddr is the address of a place whose children are known from the type: a struct, an array, or either of those behind a pointer.

A section is never enumerated, because its members come from the type rather than from the value. What a plane is asked about it is whether it is there, which is Prober.Probe.

func (SectionAddr) Path

func (a SectionAddr) Path() Path

Path is the section's address with its kind dropped.

func (SectionAddr) String

func (a SectionAddr) String() string

String is the canonical rendering of the section's address.

type SectionInfo

type SectionInfo struct {
	// contains filtered or unexported fields
}

SectionInfo is what a plane answers about a container address: information about a section, reported by a probe.

Return one of SectionPresent, SectionAbsent or SectionNull from Prober.Probe, or SectionAt where the plane holds a link and what the address names lives somewhere else. The zero SectionInfo is absence, so a driver with nothing to report returns ferry.SectionInfo{}.

Read it with SectionInfo.Presence and SectionInfo.Redirect rather than with ==. The three plain answers compare, which is what lets them be sentinels; one carrying a link holds an address, and comparing two of those compares the addresses inside them.

func SectionAt

func SectionAt(target Container) SectionInfo

SectionAt is the plane saying it holds a link at this address, and that what the address names lives at target.

It is the sentence a driver over a plane with aliases says: this section is that one. Report one hop and stop. Following the chain, keeping the set of addresses already visited and refusing a cycle are all done for you, once, so every driver tells the same redirect story and none of them has to write the loop.

switch node := d.at(addr); {
case node == nil:    return ferry.SectionAbsent, nil
case node.isAlias(): return ferry.SectionAt(d.targetOf(node)), nil
default:             return ferry.SectionPresent, nil
}

The target is an address you were handed, because nothing outside ferry builds one. A link whose target this schema does not name therefore cannot be reported at all, and resolving it, or refusing it in your own words, stays yours.

The target must be the same kind of place as the address it was reported at. What is under a section comes from the type and what is under a composite comes from the value, so a section that named a composite would be a link to somewhere its own members could not be, and it is refused.

func (SectionInfo) GoString

func (i SectionInfo) GoString() string

GoString renders a SectionInfo for a diff or a test failure: absent, present, null, or elsewhere(/primary).

func (SectionInfo) Presence

func (i SectionInfo) Presence() Presence

Presence reports what the plane holds at the address: absent, present, null, or elsewhere where the plane holds a link.

It never answers about a link's target, and that is why elsewhere is one of the four: an accessor that reported a link as absence would make a populated section read as an empty one, silently.

func (SectionInfo) Redirect

func (i SectionInfo) Redirect() (Container, bool)

Redirect is the address a link points at, and whether this answer is one.

It reports false for every plain answer, so a caller that reads presence alone is never wrong about a link, only incomplete.

type Segment

type Segment struct {
	// contains filtered or unexported fields
}

Segment is one step of an address: a kind and a text.

A driver reads both. The kind is what lets it decide whether the container above this step is a mapping or a sequence without inspecting the text, and the text is the user's, byte for byte.

func IndexSegment

func IndexSegment(i uint) Segment

IndexSegment is one position in a sequence, which is what Enumerator.Children returns for a list.

The position is unsigned because a negative one has no meaning, so the constraint is in the type rather than in a check a driver can trip over.

func NameSegment

func NameSegment(text string) Segment

NameSegment is one member of a mapping: a struct field, an object member, a map key, spelled exactly as the plane spells it.

It is what Enumerator.Children returns for a mapping. The text is the plane's own, byte for byte, and core neither folds nor normalises it.

func (Segment) Kind

func (s Segment) Kind() SegmentKind

Kind reports whether this step names a member or a position.

func (Segment) Text

func (s Segment) Text() string

Text is the segment's text exactly as the schema or the value spelled it, unescaped. Core compares it by exact byte equality and never folds or normalises it, so a driver receives the original spelling and can restore it on the way back out.

type SegmentKind

type SegmentKind uint8

SegmentKind says what a segment of an address names, and the set is closed at two: Name and Index.

It is carried rather than inferred from the text, because the only way to recover it from text is to ask whether the segment looks like a base-10 integer - which turns a map holding the key "0" into a sequence and loses the key.

const (
	// Name is an object member, a struct field or a map key.
	Name SegmentKind = iota
	// Index is a position in a sequence.
	Index
)

func (SegmentKind) String

func (k SegmentKind) String() string

String names the kind for diagnostics. It is not how the kind is rendered inside an address, which spells it with a delimiter byte of its own.

type Sink

type Sink interface {
	Bind(addrs *AddressSet) (OpenWriterFunc, error)
}

Sink is the write half of a plane, and it is a separate interface from Source rather than the other half of one.

A plane with no honest Dump - the process environment is the case - ships a Source and no Sink, so dumping to it is a compile error at the call site rather than a refusal at run time. The cost is that a driver serving both directions ships two types, since one type cannot have two Bind methods, so a round trip names the plane twice.

The separation is between the two interfaces and not between the two directions. A Sink may read the plane it writes, and a sink over a plane somebody maintains by hand usually should: editing what is already there is a read-modify-write, and it is how a file sink keeps the comments and key order of the document it saves over. What that must not become is a Dump that depends on a Load - one that needs a Source constructed or passed, or that carries state from a load into a dump. The read belongs inside the open or the commit, where the caller never sees it, and a sink says in its own documentation whether it merges into what the plane held or replaces it.

A plane that is writable in principle but not right now refuses inside the OpenWriterFunc, with an error wrapping ErrReadOnly: not at Bind, which does no I/O and cannot know, and not at the first write, which has already half-written the plane.

type SinkBinding

type SinkBinding[T any] struct {
	// contains filtered or unexported fields
}

SinkBinding is a sink bound to a compiled type: BindSink produced it, and every SinkBinding.Dump through it skips the work that produced it.

It is safe for use from many goroutines, on the same terms as Binding, and it holds no resource.

The addresses it handed the sink are the ones T names. An address a value mints - a map key, a sequence index - is minted at the write it belongs to and checked there, so one SinkBinding dumps values of different shapes: a map with two keys today and three tomorrow needs no second binding, and two dumps are never held to be injective against each other.

func BindSink

func BindSink[T any](sink Sink, opts ...Option) (*SinkBinding[T], error)

BindSink compiles T and hands sink the addresses T names, once, and returns a value to dump through many times.

b, err := ferry.BindSink[Stats](yaml.NewSink("stats.yaml"))
...
err = b.Dump(ctx, s)   // every tick

It is Dump's own first two steps, so Dump is this plus SinkBinding.Dump with the handle dropped.

It reaches no plane, so a sink that is writable in principle and not right now binds cleanly here and refuses inside the dump. It takes the same Option values every other verb takes, and it retains the schema it compiled for the binding's whole life.

It has no value in hand, so a nil sink is refused here and a nil root pointer is refused at the dump.

Example

ExampleBindSink is the same split on the write side: bind the sink once, dump through it as often as there is something to write.

package main

import (
	"context"
	"fmt"

	"github.com/onhotpath/ferry"
	"github.com/onhotpath/ferry/ferrytest"
)

// DB is the nested struct, which contributes /db/user rather than a second
// top-level address.
type DB struct {
	User string `ferry:"user"`
}

func main() {
	plane := ferrytest.MemPlane().Open()

	b, err := ferry.BindSink[DB](plane.Sink)
	if err != nil {
		fmt.Println(err)

		return
	}

	if err = b.Dump(context.Background(), DB{User: "checkout"}); err != nil {
		fmt.Println(err)

		return
	}

	back, err := ferry.Load[DB](context.Background(), plane.Source)
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(back.User)
}
Output:
checkout

func (*SinkBinding[T]) Dump

func (b *SinkBinding[T]) Dump(ctx context.Context, v T) error

Dump writes v to the plane this binding was bound to.

It is exactly what Dump does, minus the compile and the bind. The Committer and Releaser protocol, omitzero and the failure report are all the same, and every value is still encoded before any of them is written.

type Source

type Source interface {
	Bind(addrs *AddressSet) (OpenFunc, error)
}

Source is the read half of a plane: one method, handed the addresses a compiled schema determined, handing back the function that opens a Reader over them.

Binding is a phase of its own because the three pieces of state have three lifetimes. A Source holds driver config, which changes when you construct another one; an OpenFunc holds whatever the driver precomputed for this schema, such as its plane keys; and a Reader holds the plane's contents, which change on every load.

Bind does no I/O, and the missing context.Context is how the type says so. A driver must succeed at Bind against a plane it cannot reach, and fail inside the open instead. What it may fail for is what it can see without touching the plane: an address its plane cannot name, or a key function that is not injective over the set. Both name the offending address with ErrorAt, and both land before any backend call, which is what lets a plane-to-plane transfer be refused after zero calls rather than after reading the whole source.

See AddressSet for what Bind is handed, and NewKeys for the helper that does the flattening and both checks.

type Spelling

type Spelling[T, C any] interface {
	// Parse turns what the plane carries into a payload.
	Parse(c C) (T, error)
	// Render turns a payload into what the plane carries.
	Render(v T) (C, error)
}

Spelling is how one plane spells one payload: the pair of functions that turns what the plane carries into a payload and back.

T is the payload, which is what a Value holds. C is the carrier, which is what the plane hands the driver: text for an environment or a query string, bytes for a store that keeps bytes, and whatever a binary plane carries. It is a type parameter so that a plane holding bytes never passes them through a string on the way past.

type onOff struct{}

func (onOff) Parse(text string) (bool, error) { ... }
func (onOff) Render(v bool) (string, error)   { ... }

Parse refuses a carrier this plane has no reading for, and Render refuses a payload it has no writing for: a value past a size budget, or outside a charset. A Render refusal lands before anything is written, which is where a failure that could be known without touching the plane belongs.

Five rules bind every implementation, and a plane that breaks one writes data it cannot read back:

  • Parse of what Render produced returns the value it started from.
  • What Render writes is always something Parse accepts, and Parse may accept more than that. Wider in, canonical out.
  • Render is deterministic: one value, one spelling.
  • A refusal is an error and never a zero value, and never a guess. Name the text you refused, bounded: quote a limited number of bytes, escape them to one line, and say when you cut. It is the one message in ferry that carries a value the plane supplied, because it is the one message whose whole content is that value, and the bound is what keeps a plane holding secrets from losing one through a refusal.
  • A spelling changes how a value is written, never what it means.

Build one as a type with the two methods, or from a pair of functions with SpellingFunc, and stack payload steps under it with With. Ferry ships the contract and no spelling of its own: which spellings a plane has is the driver's to declare, through the driver's own options.

func SpellingFunc

func SpellingFunc[T, C any](p ParseFunc[C, T], r RenderFunc[T, C]) Spelling[T, C]

SpellingFunc builds a Spelling from a pair of functions, for a driver that has two closures and no reason to declare a type for them.

sp := ferry.SpellingFunc(
    func(text string) (bool, error) { ... },
    func(v bool) (string, error) { ... },
)

Both halves are required. A spelling built without one refuses at every call rather than at some later one, and says which half is missing.

The two functions must be pure: the same input twice gives the same output, and nothing outside them is read or written. A closure over state something else can change is a plane whose spelling changes underneath a binding that was already handed out, and no shape of constructor can stop that - which is why a driver's own options take words and numbers rather than functions.

Example

ExampleSpellingFunc declares how one plane spells a bool, from a pair of closures over words the driver owns.

The accept set is wider than the write form, which is what lets a file written by hand load while everything ferry writes stays canonical.

package main

import (
	"errors"
	"fmt"

	"github.com/onhotpath/ferry"
)

func main() {
	words := map[string]bool{"on": true, "off": false, "true": true, "false": false}

	onOff := ferry.SpellingFunc(
		func(text string) (bool, error) {
			b, ok := words[text]
			if !ok {
				return false, errors.New("no word of this plane spells a bool that way")
			}

			return b, nil
		},
		func(v bool) (string, error) {
			if v {
				return "on", nil
			}

			return "off", nil
		},
	)

	for _, text := range []string{"true", "off", "yes"} {
		b, err := onOff.Parse(text)
		if err != nil {
			fmt.Printf("%s -> %v\n", text, err)

			continue
		}

		written, _ := onOff.Render(b)
		fmt.Printf("%s -> %t -> written back as %s\n", text, b, written)
	}
}
Output:
true -> true -> written back as on
off -> false -> written back as off
yes -> no word of this plane spells a bool that way

func With

func With[T, C any](s Spelling[T, C], ts ...Transform[T]) Spelling[T, C]

With stacks payload steps under a spelling, outermost first.

sp := ferry.With(base64(), gzip(), maxSize(4<<10))

On the way out the steps run right to left and the spelling runs last, so the line above caps the size, compresses, then spells. On the way in the spelling runs first and the steps run left to right, undoing exactly what was done.

With no steps the spelling is returned unchanged. The result is a spelling like any other, so it composes again and satisfies the same rules, provided each step does.

Example

ExampleWith stacks a payload step under a spelling.

The step written last is closest to the payload, so it runs first on the way out and last on the way in: the pipeline reads as the nesting it is.

package main

import (
	"errors"
	"fmt"
	"strings"

	"github.com/onhotpath/ferry"
)

func main() {
	spelled := ferry.With[string, string](angled{}, shouted{})

	carrier, err := spelled.Render("ready")
	if err != nil {
		fmt.Println(err)

		return
	}

	back, err := spelled.Parse(carrier)
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(carrier, back)
}

// angled is a plane's own bracketing of a text payload.
type angled struct{}

func (angled) Render(v string) (string, error) { return "<" + v + ">", nil }

func (angled) Parse(c string) (string, error) {
	inner, ok := strings.CutPrefix(c, "<")
	if !ok {
		return "", errors.New("the carrier is not one this plane wrote")
	}

	return strings.TrimSuffix(inner, ">"), nil
}

// shouted is the payload step, and it inverts exactly what it applies.
type shouted struct{}

func (shouted) Apply(v string) (string, error)  { return strings.ToUpper(v), nil }
func (shouted) Invert(v string) (string, error) { return strings.ToLower(v), nil }
Output:
<READY> ready

type TextPointer

type TextPointer[T any] interface {
	*T
	encoding.TextMarshaler
	encoding.TextUnmarshaler
}

TextPointer is what NumberText and StringText take instead of two functions: a *T that declares both halves of the text pair, so both halves come from the type.

A call site never writes it. PT is inferred from T, so ferry.StringText[T]() is the whole spelling, and this name is here because it appears in the compiler error a caller reads when their type does not qualify.

It is written over *T rather than over T because the decode half has to write back. *T's method set contains T's, so a type declaring either half on a value receiver still satisfies this constraint, and the one that then does not work - UnmarshalText on a value receiver, which decodes into a copy - is refused by the constructor with the diagnostic that names the receiver.

type Transform

type Transform[T any] interface {
	// Apply runs on the way out, before the payload is spelled.
	Apply(v T) (T, error)
	// Invert runs on the way in, after the payload is read back.
	Invert(v T) (T, error)
}

Transform is a payload step that runs under a Spelling and undoes itself: compression, a size budget, a canonical form.

Apply runs on the way out, before the payload is spelled, and Invert runs on the way in, after it is read. Both may refuse, and the outbound refusal is the one that is easy to forget: a payload too big for the plane's budget, or outside the form it requires, is refused before anything is written. Invert refuses data the plane gave back that this step cannot undo, a truncated compressed stream among them, rather than handing back something plausible.

Invert of what Apply produced returns the payload it started from, for every payload Apply accepted.

type Unsetter

type Unsetter interface {
	Unset(ctx context.Context, addr CompositeAddr) error
}

Unsetter is implemented by a Writer whose plane can forget an address and everything held beneath it.

Dump calls it at a slice's or a map's own address, and it is what makes a dump a replacement of that composite rather than an addition to it: a list that lost its third element, or a map that lost a key, leaves nothing of the previous dump behind for the next load to read back. It is the only deletion a dump ever performs. A field the value omits is not written and is not removed either, so silence never deletes anything.

Unset arrives before the writes beneath that address, so a member this dump does write is written after it was forgotten and survives. A sink that stages has to keep that order across its own Commit, which for a store that deletes by key means resolving what to forget against what the dump staged rather than deleting first and hoping the ordering holds.

It is idempotent and takes no view of what is there: an address the plane does not hold is not a failure.

It is optional, and a Writer without one is refused at the open every schema whose address set holds a composite: the refusal wraps ErrPlane, is addressed at that composite, and arrives before anything is written. So a sink that does not implement it can be handed only schemas of leaves and sections.

Implement it whether or not your plane deletes anything at the moment Unset is called. A sink that stages, or one that replaces its whole plane on every dump, still declares the capability and does the forgetting where it suits it: what the method says is that the plane can forget an address, never when.

type VKind

type VKind uint8

VKind is what a plane observed at an address, and it decides which accessor on a Value can answer.

The set is closed at six: KindAbsent, KindNull, KindBool, KindNumber, KindString and KindBytes. There is no kind for a whole composite, because a composite is read one element at a time, and none for a driver-native value, because no other driver could interpret one.

const (
	// KindAbsent means the plane does not have this address at all.
	//
	// It is kind zero, so the zero [Value] is absence and a map[Path]Value
	// lookup miss reports it without being asked. Absence is what a driver
	// returns for an address its plane is silent about, and it is never handed
	// to a [Writer].
	KindAbsent VKind = iota

	// KindNull means the plane has this address and holds its own null there.
	//
	// It is a different observation from absence, and only a plane whose type
	// system contains a null can produce it. YAML and JSON can; TOML,
	// environment variables and query parameters cannot, and a driver over those
	// reports absence or a string and never a null.
	KindNull

	// KindBool is a plane-side boolean, carrying a Go bool and no text at all.
	// [Bool] is the only way to build one, so [Value.AsBool] answers from the
	// bool it was given and has no parse failure to report.
	KindBool

	// KindNumber is a plane-side number, carried as the text the plane spelled
	// it with rather than as a machine number, so no width is chosen before a
	// target type is in hand. [Value.AsInt], [Value.AsUint] and [Value.AsFloat]
	// parse it and report a failure rather than guess.
	KindNumber

	// KindString is a plane-side string, and it stays distinct from a number
	// over the same text. That is how quoting survives the boundary: `port:
	// 8080` arrives as Number("8080"), `port: "8080"` as String("8080"), and
	// each round-trips back to its own spelling.
	KindString

	// KindBytes is an opaque byte sequence, valid UTF-8 or not. How a plane
	// spells bytes - base64, hex, raw - is the driver's business.
	KindBytes
)

func (VKind) String

func (k VKind) String() string

String names the kind in lower case: absent, null, bool, number, string, bytes. An out-of-range kind renders as VKind(n) rather than panicking or reporting a neighbouring name.

type Value

type Value struct {
	// contains filtered or unexported fields
}

Value is what crosses the boundary between ferry and a plane, in both directions: a VKind and the payload that kind carries, and nothing else.

Build one with Bool, Number, String or Bytes, or use Null, read it with Value.Kind and the As accessors. The zero Value is KindAbsent, so a driver with nothing to report returns ferry.Value{}.

A number carries the plane's own spelling rather than a machine number, so no width is chosen before a target type is in hand. A bool carries a Go bool. No accessor guesses: each answers for its own kind and refuses every other one, so unparsed text never reaches a caller wearing a kind that would make it look decided.

A Value is comparable, so it is usable as a map key and assertable with ==, and it boxes nothing.

Example

ExampleValue shows what a driver hands across the boundary.

A number and a string over the same text are two different observations, and that is how a quoted 8080 and an unquoted one stay distinguishable across a round trip. An accessor asked for the wrong kind returns ferry.ErrWrongKind rather than panicking.

package main

import (
	"errors"
	"fmt"

	"github.com/onhotpath/ferry"
)

func main() {
	n, s := ferry.Number("8080"), ferry.String("8080")

	fmt.Println(n.Kind(), s.Kind(), n == s)

	i, err := n.AsInt()
	fmt.Println(i, err)

	_, err = s.AsInt()
	fmt.Println(errors.Is(err, ferry.ErrWrongKind))
}
Output:
number string false
8080 <nil>
true

func Bool

func Bool(b bool) Value

Bool returns a boolean value carrying b itself, which is what lets Value.AsBool answer without a parse that could fail.

func Bytes

func Bytes(b []byte) Value

Bytes returns a byte-sequence value holding b exactly, valid UTF-8 or not.

The bytes are copied, so the caller may reuse its buffer afterwards and no later mutation can reach a Value already handed out.

func Number

func Number(text string) Value

Number returns a numeric value carrying the plane's own spelling of it.

The text is not validated here. A plane is entitled to spell a number in a way no Go type wants, and the accessor that has a target type in hand is what finds out, so "007", "1e400" and "18446744073709551615" all survive the boundary intact and fail, if they fail at all, where the failure can be described.

func String

func String(s string) Value

String returns a string value. It stays distinct from a Number over the same text, which is how quoting survives a round trip.

func (Value) AsBool

func (v Value) AsBool() (bool, error)

AsBool returns the boolean a KindBool value carries, and ErrWrongKind for every other kind.

It has no parse failure and it never guesses. A bool carries a Go bool rather than text, so there is no unparsed "TRUE", "yes" or "1" for this accessor to read as anything: text a plane spelled arrives as a KindString and is refused here, which is where the refusal belongs.

func (Value) AsBytes

func (v Value) AsBytes() ([]byte, error)

AsBytes returns the bytes a KindBytes value carries, exactly, valid UTF-8 or not, and ErrWrongKind for every other kind.

The returned slice is a copy, so writing to it cannot reach the Value or any other holder of an equal one.

func (Value) AsFloat

func (v Value) AsFloat() (float64, error)

AsFloat parses the text of a numeric value as a 64-bit float.

A float is where precision is lost if it is lost anywhere, and that is why the plane's own text is what crossed the boundary: a caller who cannot afford the conversion calls Value.AsNumber and keeps the spelling.

func (Value) AsInt

func (v Value) AsInt() (int64, error)

AsInt parses the text of a numeric value as a signed 64-bit integer.

Out of range is an error and never a saturation, and on any error the int64 returned is zero rather than strconv's saturated bound, so a caller who ignores the error gets an obviously wrong value rather than a plausible one. The error wraps strconv.ErrRange or strconv.ErrSyntax, and a non-numeric kind gives ErrWrongKind.

func (Value) AsNumber

func (v Value) AsNumber() (string, error)

AsNumber returns the plane's own spelling of a numeric value, unparsed, and ErrWrongKind for every other kind.

It is the accessor a codec for a type wider than any Go machine number uses - big.Int is the worked example - because it hands back the digits without deciding how wide they are.

func (Value) AsString

func (v Value) AsString() (string, error)

AsString returns the text of a string value, and ErrWrongKind for every other kind.

The refusal worth knowing about is a number: accepting one would override the plane's own type information and destroy the quoting distinction the boundary preserves. It refuses a null too, so a registration that has to accept one wraps its codec in NullValue.

func (Value) AsUint

func (v Value) AsUint() (uint64, error)

AsUint parses the text of a numeric value as an unsigned 64-bit integer. It is the accessor that makes 18446744073709551615 representable at all, and it saturates no more than Value.AsInt does.

func (Value) GoString

func (v Value) GoString() string

GoString renders a Value for a diff or a test failure: absent, null, bool(true), number("8080"), string(""), bytes("\xff").

It prints the payload, so it must never be interpolated into an error message: ferry cannot know which addresses hold secrets, and neither can a caller writing a log line.

func (Value) Kind

func (v Value) Kind() VKind

Kind reports what the plane observed. It is the one accessor that cannot fail, and the one a driver or a codec switches on before choosing another.

type Word

type Word struct {
	// Name is the word as a tag spells it: a bare word, with no comma and no
	// equals sign in it.
	Name string

	// TakesValue says the word is written name=text. The text is read with the
	// same token grammar ferry's own default= uses, so a value holding a comma
	// is written in single quotes.
	TakesValue bool
}

Word is one word of an extension's vocabulary: how it is spelled, and whether it carries a value.

A word declared with a value is written name=text and one declared without it is written name, and a tag that gets that the wrong way round is refused.

type Writer

type Writer interface {
	Set(ctx context.Context, addr LeafAddr, v Value) error
}

Writer is an open plane being written to, one leaf at a time.

Set is never called with an absent value: KindAbsent is a Reader-side kind, and an omitted address gets no Set call at all rather than a Set of nothing.

It is asked only about a LeafAddr. What a dump has to say at a container's own address - that the container is there and holds nothing, or that it is null - goes to Ensurer, because a plane that cannot spell either of those should refuse rather than receive a write it will mis-store.

A Writer may also implement Ensurer, Unsetter, Preparer, Committer, Releaser and PlaneNamer. All six are discovered by assertion and none is required.

Directories

Path Synopsis
driver
env module
http module
kv module
windows module
yaml module
Package ferrytest is ferry's driver contract in executable form: the conformance suites, the round-trip property harness, the memory plane and the recording sink.
Package ferrytest is ferry's driver contract in executable form: the conformance suites, the round-trip property harness, the memory plane and the recording sink.
internal
valuewalk
Package valuewalk is the seam between core's reflect.Value-rooted walk and the one caller in this module that needs it.
Package valuewalk is the seam between core's reflect.Value-rooted walk and the one caller in this module that needs it.
Package watch turns a driver's change callback into a stream of freshly loaded values.
Package watch turns a driver's change callback into a stream of freshly loaded values.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL