exporter
Stream every record of a listing to a CSV or JSON Lines file as pages arrive, or to a writer of your own for Parquet, DuckDB or anything else.
exporter() from outbound-sdk/exporter adds outbound.export(source, { to }). It writes every record of a listing to a .csv or .jsonl file, one row at a time as pages arrive, so a large export never sits in memory. For another format, pass a RowWriter with two functions. The package adds no dependency for Parquet or DuckDB. Writing to a path uses node:fs.
Sample
Runs on the shared seed.
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createOutbound } from "outbound-sdk";
import { exporter } from "outbound-sdk/exporter";
import { memory } from "outbound-sdk/memory";
import { seed } from "../plugins-index/seed.ts";
const outbound = createOutbound({
adapter: memory({ data: seed, workspaceId: "agency" }),
plugins: [exporter()],
});
const dir = await mkdtemp(path.join(tmpdir(), "outbound-export-"));
const csv = path.join(dir, "leads.csv");
// Any listing works as the source. Pages are fetched as rows are written.
const result = await outbound.export(outbound.leads.listAll(), {
columns: ["ref", "email", "firstName", "company", "custom"],
to: csv,
});
console.log(result);
console.log(await readFile(csv, "utf-8"));
// JSON Lines, with the default columns: every field of the first record
// except `raw`.
const jsonl = path.join(dir, "campaigns.jsonl");
const campaigns = await outbound.export(outbound.campaigns.listAll(), {
to: jsonl,
});
console.log(campaigns.columns.join(", "));
const lines = await readFile(jsonl, "utf-8");
console.log(lines.split("\n")[0]);
await rm(dir, { force: true, recursive: true });
{
columns: [ 'ref', 'email', 'firstName', 'company', 'custom' ],
rows: 4
}
ref,email,firstName,company,custom
memory:agency:lead_jane,jane.doe@example.com,Jane,Northwind,"{""phone"":""555-0100""}"
memory:agency:lead_sam,sam.lee@example.com,Sam,Contoso,{}
memory:agency:lead_priya,priya.patel@example.com,Priya,Fabrikam,{}
memory:agency:lead_omar,omar.haddad@example.com,Omar,Tailspin,{}
provider, ref, schemaVersion, sourceId, workspaceId, channel, createdAt, name, status, tags
{"provider":"memory","ref":"memory:agency:cmp_founders","schemaVersion":1,"sourceId":"cmp_founders","workspaceId":"agency","channel":"email","createdAt":null,"name":"Acme: SaaS founders","status":"active","tags":["acme"]}
Options
exporter() takes no options.
outbound.export(source, options):
| Option | Type | Default | Meaning |
|---|---|---|---|
source |
AsyncIterable |
required | Anything that yields records: outbound.leads.listAll(), a campaign’s leads.listAll(), or a generator of your own. |
to |
string | RowWriter |
required | A file path ending in .csv or .jsonl, or a writer. An existing file is overwritten. |
columns |
string[] |
the first record’s fields, without raw |
The columns to write, in order. |
includeRaw |
boolean |
false |
Keep raw, the vendor’s payload, among the default columns. It has no effect when columns is set. |
It returns { rows, columns }.
How rows are written
| Format | Row | Header |
|---|---|---|
.csv |
The listed columns, comma separated. null and a missing field are empty. An object or array is written as JSON. A cell holding a comma, a double quote or a line break is quoted, with quotes doubled. Lines end with \n. |
The column names. For an empty listing, the columns you named. When you named none there is no header and the file is empty. |
.jsonl |
One JSON object per line with exactly the listed columns. A missing field is null. |
None |
RowWriter |
A JSON object with exactly the listed columns, in order, as for .jsonl. columns and includeRaw apply to it as they do to a file. A missing field is null. |
Up to you |
The default columns come from the first record only. A later record with a field the first one lacked does not get a column. Name columns when records vary.
An item of the source that is not an object is skipped and not counted.
A writer of your own
A RowWriter is write(row) and close(), both async. export awaits each write before reading the next record, so a slow writer slows the listing down and does not build a queue.
import { createOutbound, OutboundError } from "outbound-sdk";
import type { Lead } from "outbound-sdk";
import { exporter } from "outbound-sdk/exporter";
import type { RowWriter } from "outbound-sdk/exporter";
import { memory } from "outbound-sdk/memory";
import { seed } from "../plugins-index/seed.ts";
const outbound = createOutbound({
adapter: memory({ data: seed, workspaceId: "agency" }),
plugins: [exporter()],
});
// A writer is two async functions. This one collects rows in batches of
// three, the way an insert into DuckDB or a Parquet row group would.
const batchWriter = (label: string): RowWriter => {
const batches: number[] = [];
let batch: string[] = [];
return {
close: () => {
if (batch.length > 0) {
batches.push(batch.length);
}
console.log(`${label}: closed after batches of`, batches);
return Promise.resolve();
},
write: (row) => {
batch.push(String(row.ref));
if (batch.length === 3) {
batches.push(batch.length);
batch = [];
}
return Promise.resolve();
},
};
};
const result = await outbound.export(outbound.leads.listAll(), {
to: batchWriter("all leads"),
});
console.log("rows:", result.rows);
// A path that is neither .csv nor .jsonl is refused.
try {
await outbound.export(outbound.leads.listAll(), { to: "leads.parquet" });
} catch (error) {
if (OutboundError.is(error)) {
console.log(`${error.code}: ${error.message}`);
}
}
// A source that fails part way: the error reaches you, and the writer is
// closed with the rows it already has.
const interrupted = async function* interrupted(): AsyncGenerator<Lead> {
yield* outbound.leads.listAll({ max: 2 });
throw new Error("connection lost");
};
try {
await outbound.export(interrupted(), { to: batchWriter("interrupted") });
} catch (error) {
console.log("failed:", error instanceof Error ? error.message : error);
}
all leads: closed after batches of [ 3, 1 ]
rows: 4
Invalid: export() writes .csv or .jsonl files; pass a writer for other formats
interrupted: closed after batches of [ 2 ]
failed: connection lost
What it changes
It changes no capability, no record and nothing in meta. It has no layer of its own, so its position in plugins does not matter.
With other plugins
export writes whatever the source yields. A listing from the instance has been through every layer:
- With
redact, the file holds masked values andrawisnull. - Without it, the file holds the leads’ addresses and names. It is your file, on your disk. The SDK keeps no copy.
- With
clientsoridentity, the added fields are columns when the first record has them. - A
budgetormaxRequestscap stops the listing part way, as below.
When it fails
| What happened | What you get | What to do |
|---|---|---|
The path ends in neither .csv nor .jsonl |
Invalid, when the first record arrives or an empty listing ends. Nothing is written. |
Use one of the two, or pass a writer. |
| The source fails part way | The source’s error. The file is closed and keeps the rows written so far. | Do not treat the file as complete. Export again, or resume from the listing’s cursor into a new file. |
| The source fails before its first record | The source’s error. No file is created. A writer you passed is closed without a row. | Nothing. close is called once whatever happens. |
write or close rejects |
That error | Nothing more is written. |
| The listing is empty | rows: 0. A CSV holds only its header line, or nothing when you named no columns. A .jsonl file is empty, and a writer is closed without a row. |
Nothing. |
Limits
- CSV cells are not escaped against spreadsheet formulas. A value that starts with
=,+,-or@is written as it is. Lead fields come from outside your company, so treat an exported CSV as untrusted when opening it in a spreadsheet. - There is no append mode and no built-in resume. For a copy you keep in step run after run, use
sync.