The Engine is a distinct Go module from the Fetcher services. Its module path is
github.com/LerianStudio/fetcher/pkg/engine, and the services module is github.com/LerianStudio/fetcher/v2. The Engine has its own version line with path-prefixed tags (pkg/engine/vX.Y.Z). An import of the Engine inherits none of the service dependencies.Fetcher is source-available under the Elastic License 2.0, and the Engine carries the same license. You read the extraction rules you embed.The three-layer model
The guiding principle: the Engine owns what makes Fetcher Fetcher, and host applications own how Fetcher runs.
Every product that embeds the Engine therefore shares one canonical owner of datasource and extraction behavior. A rule change in the core reaches every host at the next module bump, and no host re-implements it.
The import boundary
The Engine module declares zero third-party dependencies. Its
go.mod has no require block at all, and a build-enforced test keeps it that way.
Two guards run inside the module on every go test ./..., and the module CI workflow runs them with the Go workspace turned off:
- The allowlist. Every transitive dependency of
pkg/enginemust be either a Go standard-library package or a package local to the Engine module. Any other import family fails the build, even a family that no denylist names. - A named denylist. Enumerated classes fail on top of the allowlist. They cover HTTP frameworks, message brokers, database drivers, object-storage SDKs, tenant-runtime middleware, the auth and license libraries, and standard-library shells such as
database/sql,net/http, andos/exec.
go.mod for a require line and fails the job when it finds one.
Why this matters when you embed
- No dependency conflicts. The Engine cannot pull a driver, a broker client, or an HTTP framework into your module graph. Your host keeps full control of its own versions.
- No hidden I/O. The core cannot open a socket, a file, or a database on its own. Every byte in and out crosses a port you supplied.
- A stable substitution seam. Run the whole Engine against the in-memory harness in tests. Swap real adapters in production, with no change to the calling code.
Direct mode and Store mode
An extraction plan carries a mode with three values:
direct, store, and the zero value auto.
The Engine resolves auto from the ports you wired. With a ResultSink configured it picks store mode, and without one it picks direct mode. So the mode is a consequence of your composition, not a separate switch. An explicit store request with no sink fails up front as a validation error, before the Engine touches any datasource.
The two modes return different shapes. Exactly one arm of the result is non-nil, and the JSON omits the unused arm entirely.
Direct mode
The Engine runs the plan steps and merges the rows into one map. The map keys are the datasource config name and then the qualified table. The Engine serializes that map once as indented JSON and returns the bytes inline.
Direct-mode output is deterministic. The serializer sorts the map keys. The same input therefore gives you byte-identical JSON and the same digest, whatever order the parallel steps finished in.
Your host owns everything after that. The Fetcher Worker, for example, signs the plaintext with HMAC-SHA256 and encrypts it before it stores the bytes.
Store mode
The Engine opens a stream on yourResultSink and writes the result incrementally, in constant memory. It never holds the whole result. One writer drains the extraction goroutines strictly by ascending plan-step order, and the integrity digest covers exactly the bytes written.
The wire shape is NDJSON — one JSON object per line, newline-terminated, with no enclosing array:
On an abort — a write error, an exceeded size limit, or a canceled context — the Engine abandons the writer and never calls
Close. A partial result therefore never becomes a returned reference. Treat an unclosed writer as a discarded write.
Failure and result contracts
- Fail-fast across datasources. The first failing step stops the run. The Engine never returns a partial result.
- Redacted errors. A driver error can embed a DSN, a credential, or driver internals, so the Engine discards it and returns a fixed message instead.
- Eleven error categories.
validation,not_found,unauthorized,forbidden,limit_exceeded,conflict,unavailable,connect,timeout,canceled, andinternal. Your host maps them to its own transport codes.connectstays distinct fromunavailable, andtimeoutstays distinct fromcanceled. - Five execution statuses.
pending,running,completed,failed, andcanceled. The last three are terminal. A host cancellation recordscanceled, and a deadline overrun recordsfailed. - Closed connectors. Every connector the Engine opens closes again, on the success path and on every failure path.
Limits and tenant scope
The Engine never runs unbounded. When you supply no limits, it applies these defaults:
A request can lower any limit and never raise it. An override above the default fails with a validation error that names the breached field. A zero or negative override keeps the default.
The Engine enforces the result-size ceiling twice: a cheap per-step lower bound that aborts early, and an authoritative check on the final indented payload. An over-limit result never reaches you inline and never reaches your sink.
Tenant scope is equally narrow. Every operation carries a tenant ID and nothing else — the Engine has no organization or product concept. It validates the tenant ID before any resource access, and it rejects an empty or malformed value there.
Next steps
Embed the Engine
Import it, provide the ports, and construct it with a runnable example.
Port reference
Every port, whether it is required, and what happens without it.

