For-Command-Authors

For Command Authors

Audience: you build the commands that Users compose.

The rule of authoring: pick a pattern from patterns/ and supply only your algorithm. The pattern owns every hard part — stream wiring, channels, backpressure, cancellation, teardown — and hands you a plain function to fill in. You write what the command does to a line, never how lines move.

Read The Pipeline Model first if you haven’t. Authors should also read Concurrency & Lifecycle — it explains the teardown your commands inherit for free.


The pattern catalog

Each pattern captures one shape of “how input relates to output.” Find your command’s shape, and the pattern is chosen.

PatternYou supplyOutput relationshipShell analogues
Map[In, Out]func(In) (Out, error)one in → one outtr, sed s///, cut, basename
StatefulMap[In, Out]factory → func(In) (Out, error)one → one, with memorynl (line counter)
Filter[T]func(T) (bool, error)keep or dropgrep, grep -v
StatefulFilter[T]factory → func(T) (bool, error)keep/drop, with memoryuniq (previous line)
Accumulate[T]func([]T) ([]T, error)all in → all out (reordered/trimmed)sort, tac, tail -n, shuf
Aggregate[In, Out]func([]In) (Out, error)all in → one outwc -l, sha256sum, paste -s
Expand[In, Out]func(In) ([]Out, error)one in → zero-or-more outfold, split, xargs -n1
Take[T] / Head[T]n intfirst n, then stop the upstreamhead -n, grep -m, sed q
Drop[T]n intskip first n, pass the resttail -n +N
Tap[T]func(T) errorpass through, with a side effecttee
Subprocessname, args…shell out to a real processperl, git (last resort)

Decision shortcuts


Writing a command

A command is a constructor that returns a Command. Supply the algorithm; that’s it.

func Grep(pattern string) gloo.Command[string, string] {
    return patterns.Filter(func(line string) (bool, error) {
        return strings.Contains(line, pattern), nil
    })
}

func Sort() gloo.Command[string, string] {
    return patterns.Accumulate(func(lines []string) ([]string, error) {
        sort.Strings(lines)
        return lines, nil
    })
}

func WordCount() gloo.Command[string, int] {
    return patterns.Aggregate(func(lines []string) (int, error) {
        return len(lines), nil
    })
}

Returning an error from your function propagates it down the stream and stops the pipeline — see how errors flow.

Stateful commands take a factory

A command is a value that may be reused across many pipelines, possibly concurrently. If a stateful command captured its state directly, two runs would share it. So StatefulMap and StatefulFilter take a factory called once per Execute — each run gets its own fresh state.

func Nl() gloo.Command[string, string] {
    return patterns.StatefulMap(func() func(string) (string, error) {
        n := 0 // fresh per Execute — never shared between pipelines
        return func(line string) (string, error) {
            n++
            return fmt.Sprintf("%d\t%s", n, line), nil
        }
    })
}

Never close over a mutable variable outside the factory — that’s the one way to make a command unsafe to reuse.

Early termination — Take

Take(n) emits the first n items and then stops the upstream — the SIGPIPE analogue. This is the one thing a Filter cannot do: a filter can drop items but can never say “I need nothing more,” so a filter-based head over a huge or infinite source reads everything. Take actively tears the producer down, so:

Stages downstream of Take still run to completion, so Take(3) before a sort yields three sorted lines — exactly like seq inf | head -3 | sort. Take is the building block for head, grep -m, and sed q. (Head is a readability alias for Take.)

The mechanics behind this — how a downstream stop propagates upstream without leaking — are on Concurrency & Lifecycle.


Exotic commands — FuncCommand

When no pattern fits, drop to FuncCommand[In, Out] and build the output stream yourself. This is the only place an author touches rill. Two primitives produce a stream while inheriting clean teardown:

For an origin producer (a Source, no upstream), use Generate(ctx, producer) and Wrap(ch) — same primitives without the upstream argument.

The golden rule: send returning false means the consumer has walked away — return promptly, exactly as a shell tool dies on SIGPIPE.


Sources

Build a Source to feed a pipeline. All filesystem access goes through afero.Fs — never os.Open. Tests use afero.NewMemMapFs().

ConstructorProducesNotes
SliceSource(items)Source[T]in-memory slice — the here-string
FileSource(fs, files)Source[string]lines from files
ByteFileSource(fs, files)Source[[]byte]same, as independent []byte copies
ReaderSource(readers)Source[string]lines from io.Readers
ByteReaderSource(readers)Source[[]byte]same, as []byte
StreamOf(items…)Stream[T]a finished stream — the test here-string

Sources are cancellation-aware and handle lines far larger than bufio.Scanner’s 64 KB default (up to MaxLineSize, 1 GiB) via NewLineScanner, so minified JSON and long log lines never abort a pipeline.


Where things belong

Per the framework’s constitution: stream wiring lives in framework/, patterns in framework/patterns/, and each command in its own cmd-* module. Subprocess is a last resort — if an algorithm can be done in pure Go, it must be. New patterns require maintainer approval; exhaust the existing set first.

Next: Errors & Testing shows how to surface errors and how to test a command with in-memory streams.