setu:: docs
Start

Core concepts

Six nouns run all of Setu: connections, datasets, pipelines, runs, agents, and secrets. Learn how they fit and you can read the rest of the docs fluently.

A migration in Setu reads like a sentence: a pipeline moves data from a source connection to a target connection; you pick which datasets to move; each execution is a run; and when a side lives in a private network, an agent runs that side using secrets that never leave the edge. Here is each noun in detail, grounded in the real types and SQL.

Connections & placement

A connection is a saved, testable handle to one system — a Postgres database, an S3 bucket, a local folder. It has a provider (the connector kind, renamed from kind in the dataset-centric migration) and a JSONB config. The config never stores plaintext credentials: it holds ${secret.NAME} references that are resolved at execution time. On the write path the config is persisted verbatim — the handler explicitly keeps secret refs intact and never seals plaintext into the row.

The field that makes Setu interesting is placement. Every connection is either cloud (Setu can reach it directly) or agent (it lives behind a bound agent, identified by agent_id). This one pair drives all routing — it is a pure function:

apiserver/internal/api/placement.go — stageFor & derivePlacementgo
// stagePlacement says where one stage of a run executes: the cloud worker
// (Cloud=true) or a specific bound agent (Cloud=false, AgentID set).
type stagePlacement struct {
	Cloud   bool
	AgentID uuid.UUID
}

// derivePlacement decides where extract and load run from the two connection
// placements. It is a pure function — no I/O, trivially unit-tested.
func derivePlacement(srcPlacement string, srcAgent uuid.UUID, dstPlacement string, dstAgent uuid.UUID) runPlacement {
	return runPlacement{
		Extract: stageFor(srcPlacement, srcAgent), // reads the source
		Load:    stageFor(dstPlacement, dstAgent), // writes the destination
	}
}

func stageFor(placement string, agent uuid.UUID) stagePlacement {
	if placement == "agent" {
		return stagePlacement{Cloud: false, AgentID: agent}
	}
	return stagePlacement{Cloud: true}
}

Because a pipeline’s extract and load are routed independently, the two placements combine into four topologies — all sharing the same blob seam:

cloud → cloudBoth reachable. The worker does it all — direct in one pass for small deltas, or staged through blob for large ones.
agent → cloudExtract runs on the agent and stages to blob· the cloud worker loads from that blob. The source’s credentials never leave the edge.
cloud → agentThe worker extracts and stages· a load job is handed to the destination’s agent, which loads from blob into a target the cloud can’t reach.
agent → agentBoth stages run on (possibly different) agents· the blob is the only shared medium between them. The cloud only orchestrates.

A connection’s config is stored as-is. Here is an agent-placed Postgres config; the credential is a reference, meaningless without the agent’s local secret store:

a connection config (stored verbatim in connection.config)json
{
  "host": "10.0.4.12",
  "port": 5432,
  "database": "orders",
  "user": "setu_ro",
  "password": "${secret.ORDERS_DB_PASSWORD}",
  "sslmode": "require"
}
Testing a bound connection
When you hit “Test” on an agent-placed connection, the cloud can’t reach it — so the apiserver enqueues a test agent_job and waits for the runtime to run it and report back. Discover and preview take the same round-trip: the cloud dispatches a discover/preview job and polls for the result.

Datasets & the catalog

A dataset is one movable stream inside a source connection — a table, a view, a file, a collection, an API. You don’t create them by hand: discovery asks a source to list its streams, infers a schema for each, and registers them into the catalog. The catalog is the browsable inventory the mapper and pipeline builder read from. The row shape (migration 0006, plus primary_key in 0021):

physical_nameThe real name in the source (e.g. public.orders). UNIQUE (connection_id, physical_name).
typeThe stream kind: table | view | file | collection | api.
schemaThe inferred field list (name, type, nullability) stored as JSONB.
row_estimateA cheap size hint used to plan and to preview.
primary_keytext[] — drives upsert/merge and gives a staged load its conflict target.
statusactive | missing | archived — reconciliation flips a vanished stream to missing rather than deleting it.
tags · folder_idUser curation: GIN-indexed tags and a folder tree for organising the catalog.

Discovery is reconciling, not additive: it upserts every stream it finds and sweeps away ones that vanished — all inside one transaction so the catalog is never left half-updated. There is a deliberate safety valve for a transient empty discovery:

apiserver/internal/catalog/register.go — reconcile in one passgo
for _, d := range discovered {
	row, err := q.UpsertDataset(ctx, sqlc.UpsertDatasetParams{
		ConnectionID: conn.ID, Provider: conn.Provider,
		PhysicalName: d.PhysicalName, Type: d.Type,
		Schema: d.Schema, RowEstimate: d.RowEstimate,
		PrimaryKey: pk, WorkspaceID: workspaceID, // dataset inherits its connection's workspace
	})
	// ...
}
// Empty-discovery safety: never mass-flag the whole catalog missing on a
// transient empty discovery. Skip the sweep and report Missing=0.
if len(discovered) == 0 {
	return Summary{Total: len(rows)}, rows, nil
}
missing, err := q.MarkDatasetsMissing(ctx, sqlc.MarkDatasetsMissingParams{
	ConnectionID: conn.ID, Present: PresentNames(discovered),
})
Discovery on the edge
For an agent-placed connection the cloud can’t enumerate streams itself, so it dispatches a discover job. When the agent returns the streams, onAgentDiscoverResult runs the same catalog.Register reconciliation — identical upsert-plus-sweep, just fed from a connection the cloud never touched.

Pipelines

A pipeline is the plan: a source, an ordered chain of operators (transform/validate), and a destination. Internally it’s stored as a canonical Document — “everything is an operator,” with the source and destination as terminal nodes wired by edges. The node/edge model is DAG-ready, but execution today is linear.

engine/pipeline/document.go — the canonical modelgo
// Document is the JSON-serialised pipeline body stored in pipeline.spec.
type Document struct {
	Version int            `json:"version"`
	Params  map[string]any `json:"params,omitempty"`
	Vars    map[string]any `json:"vars,omitempty"`
	Nodes   []Node         `json:"nodes"`
	Edges   []Edge         `json:"edges,omitempty"`
}

// SyncConfig describes how a source reads across runs: a full re-read (default)
// or an incremental read bounded by a cursor column.
type SyncConfig struct {
	Mode           string            `json:"mode,omitempty"` // "full" | "incremental"
	CursorField    string            `json:"cursorField,omitempty"`
	CursorByStream map[string]string `json:"cursorByStream,omitempty"` // per-stream override
}

type DestinationConfig struct {
	ConnectionID string `json:"connectionId"`
	Mode         string `json:"mode,omitempty"`         // append|overwrite|upsert|merge
	SchemaPolicy string `json:"schemaPolicy,omitempty"` // create|strict
	TargetPrefix string `json:"targetPrefix,omitempty"` // e.g. "staging_"
}

Three settings on the terminal nodes govern semantics. Their allowed values come straight from the declarative DestinationSpec/SourceSpec the console renders forms from:

write modeappend (add rows), overwrite (replace the target), upsert (insert-or-update on the primary key), or merge.
sync modefull (re-read everything, the default) or incremental (read only rows past a cursor column, tracked per stream across runs via pipeline_state).
schema policycreate migrates the target schema to fit; strict requires it to already exist.

Most pipelines are created from a source, a destination, and a mode — Setu builds the canonical straight-line Document for you with BuildLinear, then derives the denormalised source_connection_id/target_connection_id columns from the validated Document so the row and the spec can never disagree. A full Document may also be supplied directly:

a pipeline Document (source → destination, incremental upsert)json
{
  "version": 1,
  "nodes": [
    { "id": "source", "type": "source",
      "config": { "connectionId": "…", "streams": ["public.orders"],
                  "sync": { "mode": "incremental", "cursorField": "updated_at" } } },
    { "id": "destination", "type": "destination",
      "config": { "connectionId": "…", "mode": "upsert", "schemaPolicy": "create" } }
  ],
  "edges": [ { "from": "source", "to": "destination" } ]
}

Runs

A run is one execution of a pipeline. It carries a status lifecycle, a phase (where in the extract/load machine it is), a dry_run flag, live counters, an append-only event log, and — when data is staged — a run artifact pointing at the blob.

migration 0001 / 0002 / 0019+ — the run row (abridged)sql
CREATE TYPE run_status AS ENUM ('pending','running','succeeded','failed','cancelled');

CREATE TABLE run (
    id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    pipeline_id  uuid NOT NULL REFERENCES pipeline(id) ON DELETE CASCADE,
    status       run_status NOT NULL DEFAULT 'pending',
    phase        text NOT NULL DEFAULT 'extract'         -- 0019, widened by 0022
                 CHECK (phase IN ('extract','load','direct')),
    rows_read        bigint NOT NULL DEFAULT 0,
    rows_written     bigint NOT NULL DEFAULT 0,
    rows_quarantined bigint NOT NULL DEFAULT 0,           -- 0002
    rows_total       bigint NOT NULL DEFAULT 0,           -- 0020: progress denominator
    dry_run          boolean NOT NULL DEFAULT false,      -- 0002
    cancel_requested boolean NOT NULL DEFAULT false,      -- 0011: cooperative cancel
    error        text,
    started_at   timestamptz,
    finished_at  timestamptz
);

status is the outer lifecycle; phase is the inner extract/load position. They are orthogonal — a run is running while its phase moves through extract then load (or sits at direct):

phaseProgress through the E/L machine: extractload, or direct. A resumed run reads its phase to pick up where it stopped — a run past extract loads purely from blob and never touches the source.
dry_runRuns the full extract/transform but skips the write — for validating a pipeline safely.
run_eventThe append-only live log (level, stream, message, running row counts) streamed to the console’s run theater over SSE.
run_artifactA pointer to the staged blob prefix and its totals, plus an expires_at the GC sweeps, so a later stage (or a different host) can load it.
extract/load_checkpointPer-(run, stream) resume cursors: the last committed blob part and rows already loaded, so a retry doesn’t redo finished work.
cancel_requestedA cooperative cancel flag polled during the copy — a long load stops cleanly rather than being killed.

The console watches a run by tailing its event log as Server-Sent Events until the run reaches a terminal status:

GET/api/v1/runs/{id}/eventssession · SSE

Streams run_event rows as they append (700ms poll, ~10min cap), closing when status is succeeded, failed, or cancelled. Cancellation is a separate POST /runs/{id}/cancel that is idempotent — repeated clicks are harmless.

Staged vs direct execution

Setu has two ways to run a migration, and it auto-decides between them per run — you never configure it:

  • Direct (phase = direct) — stream rows straight from source through the operator chain to the destination in one pass, no blob. Chosen only when both sides are cloud-reachable and it’s an incremental sync whose delta is at or below a row threshold (small deltas don’t benefit from staging’s resume/replay, so the blob round-trip is pure overhead).
  • Staged (phase = extractload) — extract writes rows to the blob store as parts; a separate load reads them back into the destination. This is mandatory whenever the run crosses the cloud↔edge boundary (the blob is the only shared medium) and is used for large deltas even in-cloud, so a load can resume from its checkpoint without re-reading the source.
worker/internal/migrate/worker.go & staged.go — the mode decisiongo
// directMaxRows: at/below this incremental delta, stream DIRECTLY (no blob).
// Default 50000 (~one blob part). Env: SETU_DIRECT_MAX_ROWS (0 disables direct).
func directMaxRows() int64 { /* env override, else 50000 */ }

// Only a fresh (still-extracting) run whose load is NOT pinned to an agent can go
// direct; a run resumed at the load phase must read from the staged blob.
if run.Phase == "extract" && !loadOnAgent {
	forceDirect := !stagedEnabled()          // SETU_STAGED_EXECUTION=off kill switch
	autoDirect := false
	if res.Incremental {
		plans, todo, total, sized, err := w.directDelta(ctx, src, res, streams, run.PipelineID, ecs)
		// ...
		autoDirect = sized && total <= directMaxRows() // small delta → direct
	}
	if forceDirect || autoDirect {
		return w.runDirect(runCtx, ctx, q, runID, src, dst, todo, res, run.DryRun, ...)
	}
}
// Otherwise: extract the source into blob, set phase=load, then load from blob.

The staged path also handles the cloud→edge hand-off. After the worker stages the extract, if the destination is agent-placed it dispatches a load job to that agent and returns a sentinel so the run isn’t marked succeeded — the run continues on the edge and completes when the agent reports its load result:

worker/internal/migrate/worker.go — hand load to the destination's agentgo
if run.Phase == "extract" {
	// ...runExtract stages to blob...
	q.SetRunPhase(ctx, sqlc.SetRunPhaseParams{ID: runID, Phase: "load"})
	if loadOnAgent {
		q.CreateAgentJob(ctx, sqlc.CreateAgentJobParams{
			AgentID: uuid.UUID(dstConn.AgentID.Bytes), WorkspaceID: workspaceID,
			Kind: "load", RunID: pgtype.UUID{Bytes: runID, Valid: true},
			ConnectionID: pgtype.UUID{Bytes: dstID, Valid: true}, Payload: payload,
		})
		return runner.Stats{}, errHandedOffToAgent // run continues on the edge
	}
}
Why staging is the seam
Because extract and load are decoupled through the blob, an agent can extract private data and stage it, then the cloud worker loads it into a cloud destination — neither side ever connects to the other. Both hosts format the blob prefix as ws/<ws>/pl/<pipeline>/run/<run>/ so the reader finds exactly what the writer staged. It’s the same mechanism that makes runs resumable.

Edge secrets

Credentials for agent-placed connections must never reach the cloud. Setu enforces this with ${secret.NAME} references: the control plane stores and ships the reference verbatim inside a job payload, and the agent resolves it locally at extract time from its own secret store.

agent/internal/config/config.go — the edge secret storego
// Secrets is the edge secret store: name→value. Connection configs pulled
// from the control plane carry ${secret.NAME} references (never plaintext);
// the agent resolves them from this map at extract time. Populated via
// `setu secret set <NAME> <VALUE>` and written 0600 under the user's config dir.
Secrets map[string]string `json:"secrets,omitempty"`

The extract/load spec the agent fetches carries the config with references intact — the apiserver returns it verbatim and comments that it must never resolve secrets on the cloud side:

apiserver/internal/api/agent_jobs.go — spec carries refs, not secretsgo
// Config is returned VERBATIM (secret refs intact). Never resolve here.
cfg := json.RawMessage(conn.Config)
writeJSON(w, http.StatusOK, extractSpecDTO{
	RunID:      runID,
	BlobPrefix: runBlobPrefix(ws, run.PipelineID, runID),
	Provider:   conn.Provider,
	Config:     cfg,               // {secret.X{'}'} still unresolved
	Streams:    streams,
	Blob:       engineblob.SpecFromEnv(), // same backend the cloud reads back from
})

Cloud connections resolve their references the mirror-image way. The apiserver’s secrets.Resolver reads the workspace’s secret table — values sealed with AES-256-GCM (ciphertext + nonce, never plaintext) — and substitutes them just before building the connector. So ORDERS_DB_PASSWORD exists as plaintext only on the host that ran setu secret set; a cloud secret exists as plaintext only transiently inside the apiserver at build time.

Agents

An agent is a single static binary a customer runs inside their own network. It has no inbound ports: it dials out to the control plane, enrolls once, then long-polls for jobs and runs the same engine locally.

enrollExchange a one-time enrollment token for an agent key. The token is consumed atomically; the returned key is shown once and only its SHA-256 is stored server-side.
runLong-poll /agent/v1/jobs/next, lease a job, fetch its extract/load spec, execute via the engine, and post the result.
kindstest, discover, preview (interactive, connection-scoped) and extract, load (run-scoped data movement).
livenessEach authenticated call touches last_seen_at; the orchestrator treats an agent as reachable only if it checked in within ~90s, and fails a run fast (or skips a schedule) when its runtime is offline.

Schedules

A pipeline can run automatically on a cron cadence. A pipeline_schedule is one row per pipeline (PK = pipeline_id) with a 5-field cron, an enabled flag, an optional dry_run, and a next_run_at the scheduler polls. A partial index makes the firing predicate cheap:

migration 0008 — pipeline_schedulesql
CREATE TABLE pipeline_schedule (
    pipeline_id  uuid PRIMARY KEY REFERENCES pipeline(id) ON DELETE CASCADE,
    cron         text NOT NULL,                 -- 5-field cron expression
    enabled      boolean NOT NULL DEFAULT true,
    dry_run      boolean NOT NULL DEFAULT false,
    next_run_at  timestamptz,                   -- NULL when disabled
    last_run_at  timestamptz
);
CREATE INDEX pipeline_schedule_due_idx
    ON pipeline_schedule (next_run_at)
    WHERE enabled AND next_run_at IS NOT NULL;

Firing goes through the same placement-aware path as a manual run (FireScheduleorchestrateRun). One important difference: if the pipeline’s bound runtime is offline, a scheduled occurrence does not create a run destined to fail — it notifies the workspace owners and skips, catching up on the next cadence once the agent is back. Because all scheduler state lives in Postgres, an external cron can also force a pass via POST /internal/scheduler/tick — so due schedules still fire even if a free-tier host slept through its in-process ticker.

The six nouns, one breath
A pipeline moves selected datasets from a source connection to a target; each run executes it — direct if both sides are cloud and the delta is small, staged through blob otherwise; an agent-placed connection routes its stage to an agent that resolves secrets at the edge; and a schedule fires the whole thing on cron. That’s Setu.