Getting started
This guide builds a small pipeline that loads, normalizes, and returns a list of rows. It then shows how to run the pipeline, preview selected steps, and test it.
Install
Install the package in your TypeScript project. Library imports require Node.js 22.6 or later:
npm install tubeless
The same package works with pnpm add tubeless, yarn add tubeless, and
bun add tubeless. The README quick start is a complete first program.
Add tubeless/cli for terminal commands, and
tubeless/project for pipeline projects. Neither import loads storage or Studio;
the executable supplies those optional tools.
The executable requires Bun 1.3.14 or later:
bunx tubeless --help
Command-by-command usage is in the CLI.
Runtime support
Library imports (tubeless and every subpath) are ESM-only and require
Node.js 22.6 or later. They are dependency-free compiled JavaScript.
The executable uses #!/usr/bin/env bun, so Bun must be installed and available
on PATH even when npm or npx installs the package.
Supported operating systems are Linux and macOS. Windows is untested.
Try the CLI
From a Tubeless checkout, list and run a pipeline directly from a project:
bunx tubeless list --project examples/tubeless.project.ts
bunx tubeless inspect --project examples/tubeless.project.ts validated-import
bunx tubeless run --project examples/tubeless.project.ts validated-import -- --source rows.txt
The project contains only pipelines. The workbench derives --source from the
pipeline’s input schema; there is no command wrapper.
In your app, export defineProject("my-app", [MyPipeline]) from
tubeless.project.ts, then run bunx tubeless list with no --project.
Pipelines whose options schema exposes Standard JSON Schema input metadata get
their flags automatically. Register commands directly in the list for custom command inputs
or presentation. See examples/automatic-cli.ts,
examples/project/tubeless.project.ts,
and the CLI.
1. Define domain options
Start with the data your pipeline needs. Here, the caller supplies an array of lines. Options such as dry-run mode and step selection control execution, so they are passed separately from this input.
interface ImportOptions {
lines: readonly string[];
}
const lines = [" First row ", "", "Second row"];
2. Create typed steps
Call createSteps once and take its step constructor for these options. The
load step returns the input lines. The normalize step declares dependsOn: [load],
so it receives the output of load as a typed input. TypeScript infers that
rows is a read-only array of strings.
import { createSteps } from "tubeless";
const { step } = createSteps<ImportOptions>();
const load = step("load", {
description: "Read source rows",
run: (_inputs, context) => context.options.lines,
});
const normalize = step("normalize", {
dependsOn: [load],
description: "Normalize non-empty rows",
run: ({ load: rows }) => rows.map((row) => row.trim().toLowerCase()).filter(Boolean),
});
IDs should remain stable: plans, reports, hooks, tracing, and CLI selection all use them.
3. Define the result
A single-goal pipeline needs only its ID and steps:
import { definePipeline } from "tubeless";
const ImportPipeline = definePipeline({
id: "import",
steps: [load, normalize],
});
Running without selection controls selects every step. Omitted definition
targets exposes the last step in execution order, normalize, as a public goal
callers can explicitly select. Running with { targets: ["normalize"] } selects
it and load, because normalization requires its output; unrelated steps would
be excluded.
Omitted finalize returns that step’s output. Its type includes all possible step
outputs plus undefined; here, callers can read it as readonly string[] | undefined.
If a dry run or filter leaves the output absent, the result is undefined.
The dependency graph determines execution order, with declaration order breaking
ties between ready steps. These defaults use that full order even when a run
filters or skips steps. An unfiltered run still executes all steps.
Provide targets to expose different goals, or targets: [] to expose none.
Provide finalize to transform outputs or require them explicitly:
import { requireOutputs } from "tubeless";
const SummaryPipeline = definePipeline({
id: "import-summary",
steps: [load, normalize],
finalize: requireOutputs([normalize], ({ normalize }) => ({
count: normalize.length,
rows: normalize,
})),
});
requireOutputs makes those output properties required in the callback and fails
finalization if any were not published. Tubeless checks that every public target,
including an implicit last-step target, includes the required finalizer steps.
Use a plain finalizer when your application intentionally accepts partial results.
4. Run or preview the pipeline
const value = await ImportPipeline.runOrThrow({ lines });
const report = await ImportPipeline.run({ lines }, { continueOnError: true });
const plan = ImportPipeline.plan({ dryRun: true });
const normalizePlan = ImportPipeline.plan({ targets: ["normalize"] });
runOrThrowreturns the finalized value only when the run succeeds and throwsPipelineExecutionErrorfor every unsuccessful run.continueOnErrormay let independent steps finish, but it never makes their failures successful.runreturns a report with step statuses, errors, timings, and any final result. Use it when your caller needs to inspect failed or partially completed work. Checkreport.finalizedto narrowreport.valueto the exact result type; a failed best-effort run may still be finalized.planshows which steps are selected and which will be skipped. It does not execute steps or validate domain inputs.targetsselects declared downstream goals plus their required inputs and failure gates.stepIdsis an exact low-level filter and cannot be combined withtargets. See core concepts for examples of both controls.
For a CLI script, import definePipelineCommand from tubeless/cli and
wrap the pipeline. It supplies help, target and step selection, dry-run flags,
and cancellation handling. See the CLI and
cli-job.ts.
5. Define a project
Collect application pipelines by passing them directly to defineProject:
import { defineProject } from "tubeless/project";
const project = defineProject("data-jobs", [ImportPipeline, SummaryPipeline]);
const summary = await project.get("import-summary").runOrThrow({ lines });
The stable project ID remains literal on project.id. Pipeline IDs do too, so
get returns the exact pipeline with its own option and result types. For union
or widened pipeline IDs, get retains all matching candidate types. A project
is only an immutable collection; the selected pipeline’s existing methods do the
work. For YAML or JSON, call compilePipelineDocument(document, registry) first,
then use compiled.get(id) directly or register selected compiled pipelines;
see declarative pipelines.
Use finalize: normalize when the pipeline should require and return that step’s
output unchanged. The result keeps the step’s exact output type; filtering it out
or structurally skipping it makes finalization fail. See the
precise result recipe.
Let definePipeline infer its type arguments to preserve literal IDs. If you need
an explicit result contract, annotate the finalizer’s return type:
type Summary = { count: number };
const CountPipeline = definePipeline({
id: "count-rows",
steps: [load, normalize],
finalize: requireOutputs([normalize], ({ normalize }): Summary => ({
count: normalize.length,
})),
});
CountPipeline.id retains the literal "count-rows", and its result is Summary.
Calls such as definePipeline<Steps, Result>(...) instead default the omitted ID
type argument to string. TypeScript does not infer the remaining parameters
after explicit type arguments; adding another generic or overload cannot change
that rule for the same call syntax. Those pipelines still run, but project lookup
accepts string IDs and checks missing IDs at runtime. See
TypeScript’s generic parameter defaults.
6. Draw the pipeline
toMermaid returns Mermaid flowchart text describing the step dependencies.
It does not run the pipeline. It uses name when present and otherwise displays the
stable step ID.
const diagram = ImportPipeline.toMermaid({ direction: "LR" });
Required data dependencies use solid arrows. Optional inputs and failure gates
use labeled dotted arrows. Set includeDescriptions: true when the extra node
text is useful. The same source is available from tubeless graph; see
the CLI.
7. Test without real delays
import { createPipelineTestRuntime } from "tubeless/testing";
const test = createPipelineTestRuntime({ cwd: "/workspace" });
const result = await test.run(ImportPipeline, { lines });
test.clock.now();
test.logs;
test.statuses;
test.latestProgress;
The test runtime records logs, status changes, and progress. Its default sleep
advances a test clock immediately, so tests do not wait in real time. Call
test.abort() to test cancellation. Use your test framework’s assertions to
check the captured events and results.
The test runtime does not replace filesystem or network calls. Supply fake I/O in tests that must avoid real side effects.
Next
Continue with the recipe index, or read core concepts before implementing failure-sensitive writes.
To validate values from files or external services, use the Standard Schema
support in validated-boundaries.ts. Persist and
inspect local runs with the studio, or open a finished portable
trace with tubeless history --trace run.ndjson.