Watching one ref
Sometimes you do not want a config struct. You want to watch one value and react when it changes: a TLS certificate, a rotating API token, a feature flag. WatchRef does exactly that and nothing else.
func WatchRef(ctx context.Context, p Provider, ref Ref, opts ...Option) <-chan Update
You hand it a provider and a ref, and you get a channel of updates.
A complete example
Watch one Vault secret and rotate a database pool whenever it changes:
package main
import (
"context"
"log"
"github.com/xavidop/mamori"
"github.com/xavidop/mamori/providers/vault"
)
func main() {
ctx := context.Background()
// 1. Build the provider yourself. See "Getting a provider" below:
// a blank import is NOT enough here.
p := vault.New()
// 2. Parse the ref string you would otherwise put in a source: tag.
ref, err := mamori.ParseRef("vault://secret/data/app#db_password")
if err != nil {
log.Fatal(err)
}
// 3. Watch it. The first update arrives with the current value.
for u := range mamori.WatchRef(ctx, p, ref) {
if u.Err != nil {
log.Printf("vault: %v", u.Err) // transient; the channel stays open
continue
}
pool.Rotate(string(u.Value.Bytes))
log.Printf("rotated to version %s", u.Value.Version)
}
// The loop exits only when ctx is cancelled and the channel closes.
}
The ref string is the same grammar you would write in a source: tag, so all of these are valid:
mamori.ParseRef("vault://secret/data/app#db_password")
mamori.ParseRef("k8s-secret://default/tls-cert#tls.crt")
mamori.ParseRef("aws-sm://prod/stripe#key")
mamori.ParseRef("file:///etc/tls/tls.crt")
mamori.ParseRef("env:LOG_LEVEL")
See Ref grammar for the full syntax, including ?decode= pipelines and JSON Pointer fragments.
Getting a provider
This is the part that trips people up, so it is worth stating plainly.
Everywhere else in mamori you register a provider with a blank import and the library finds it by scheme:
import _ "github.com/xavidop/mamori/providers/vault" // works for Load and Watch[T]
That is not enough for WatchRef. WatchRef takes a Provider value as an argument, and mamori’s scheme lookup is internal, so there is no exported way to ask “give me the provider for vault://”. You construct the one you need directly:
import "github.com/xavidop/mamori/providers/vault"
p := vault.New() // or vault.New(vault.WithClient(myClient)), etc.
Each provider package exports its own constructor and options; see that provider’s page for what it takes. If you want scheme-based dispatch across many providers without wiring them yourself, that is what Load and Watch[T] are for.
What you give up versus Watch[T]
WatchRef is deliberately the low-level primitive. Compared to Watch[T] you lose:
- Validation. No struct means no
validate:tags. Nothing checks what arrives. - The atomic swap and last-good snapshot. You get raw updates, including error updates, and decide yourself what each one means. There is no
Get()still serving the last good value while you work that out. - The
PreApplygate. Nothing stands between an update and your code acting on it, so a rotated credential that does not work reaches you anyway. Changediffing. AnUpdateis one value or one error. There is noChanged("Field").Status()andHealth(). There is no watcher object, so nothing reports readiness or staleness.- History and pinning. No snapshot store, so nothing to pin or roll back to.
If you want any of those, use Watch[T] with a one-field struct. That is usually the better answer:
type Config struct {
DBPassword secret.String `source:"vault://secret/data/app#db_password"`
}
Reach for WatchRef when you are building infrastructure rather than an application: mamori’s own config server uses it to watch each binding, because it never has a struct to fill in the first place.
Which options apply
WatchRef accepts the same Option values as Load and Watch, so you do not need a second vocabulary. Only four actually do anything here, because only the polling loop reads them:
| Option | Effect on WatchRef |
|---|---|
WithPollInterval | How often a polled ref is re-read (default 30s) |
WithJitter | Randomizes that interval (default 0.2) |
WithBackoff | Backs off after repeated resolve failures (off by default) |
WithClock | Replaces the clock, for tests |
Everything else, including WithValidator and PreApply, is accepted without error and does nothing, because there is no validation step or gate here for it to attach to.
The channel contract
Three rules, and they matter because there is no Get() to fall back on:
- The first update carries the current value, so you do not need a separate initial read.
- An error is an update, not a termination. A transient failure arrives as an
Updatewith a non-nilErr, and the channel stays open. Log it and keep looping; the next successful resolve arrives on the same channel. - Closure means done. The channel closes only when
ctxis cancelled. That is the signal to stop, not an error value.
for u := range mamori.WatchRef(ctx, p, ref) {
if u.Err != nil {
continue // transient, keep going
}
use(u.Value.Bytes)
}
// Only reached after ctx is cancelled.
Native watch versus polling
You do not choose this, and you do not need to: WatchRef picks the best available method for whichever provider you passed, exactly as Watch[T] does for every field.
Providers with a real change-notification API (Kubernetes, Consul, etcd, Redis, Postgres, MongoDB, Firestore, LaunchDarkly) are watched natively, so a change reaches you as soon as the backend publishes it. Everything else (AWS, GCP, Azure, Vault, and the rest) is polled on the interval above. If a native watch cannot start, WatchRef falls back to polling rather than failing.
The Watch column in the provider table tells you which you get.