Stream an export and clean up failure
Write incrementally, publish only a completed file, and leave the last successful export available when generation fails.
On this page
Source-backed MarkdownSet up
Use the standalone app and development tools from Installation. This example needs only Node.js and temporary local storage. It does not open a database or contact a provider.
Copy the export
The writer accepts an already-selected disk and authorized destination. The runner owns its temporary directory and removes it in finally.
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/storage/examples/. examples/Run it
Expect lines: 50001 (header plus 50,000 rows), pendingFiles: 0, and true values for completion, interruption rejection, preserving the previous file and recovery. Counting the returned stream does not retain the whole export.
npx tsx examples/runStreamExport.tsStage before publishing
Readable.from() produces one row at a time. writeStream() consumes the stream before move() publishes it. A unique staging path prevents a failed producer from truncating the previous destination.
The catch path destroys the source and removes its partial staging object. Storage may wrap the original stream error in cause. If cleanup itself fails, surface that failure and reconcile the owned temporary path; do not pretend the operation completed.
These synthetic CSV values are safe. Real exports must escape delimiters, quotes and newlines, and consider spreadsheet formula injection when handling untrusted cells.
import { randomUUID } from 'node:crypto';
import { Readable } from 'node:stream';
import type { StorageDisk } from '@db3.ai/app/storage';
/** Options for a bounded-memory CSV export and deliberate failure in the lab. */
export interface CsvExportOptions {
/** Number of synthetic rows to generate without retaining all rows in memory. */
rows: number;
/** Lab-only failure point; omit for a normal export. */
failAt?: number;
}
/**
* Streams a generated CSV to a staging path, publishing only after success.
*
* The staging path is owned by this operation. A failed stream removes that
* object and leaves the previous destination untouched. Cross-provider moves
* are not assumed atomic; concurrent publication needs an application policy.
*
* @param disk - Application-owned disk selected before the operation.
* @param destination - Authorized relative output path, not raw request input.
* @param options - Row count and optional controlled failure point.
*/
export async function writeCsvExport(disk: StorageDisk, destination: string, options: CsvExportOptions): Promise<void> {
if (!Number.isInteger(options.rows) || options.rows < 0 || options.rows > 1_000_000) throw new Error('rows must be an integer between 0 and 1000000');
const staging = `pending/${randomUUID()}.csv`;
const stream = Readable.from(csvRows(options));
try {
await disk.writeStream(staging, stream, { mimeType: 'text/csv', visibility: 'private' });
await disk.move(staging, destination);
} catch (error) {
stream.destroy();
if (await disk.exists(staging)) await disk.delete(staging);
throw error;
}
}
/** Produces one safe synthetic CSV row at a time; real exports must escape data. */
function* csvRows(options: CsvExportOptions): Generator<string> {
yield 'id,value\n';
for (let index = 0; index < options.rows; index++) {
if (index === options.failAt) throw new Error('Controlled export interruption');
yield `${index},note-${index}\n`;
}
}
Consume without buffering
Use pipeline() to connect the readable to a writable and propagate errors. Avoid readToBuffer() or readToString() when the file size is unbounded. The runner counts bytes/newlines incrementally and compares the byte count with storage metadata.
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { pathToFileURL } from 'node:url';
import { Storage } from '@db3.ai/app/storage';
import { writeCsvExport } from './writeCsvExport';
/**
* Exercises streamed file publication, interrupted-write cleanup and retry.
* @returns Counts and cleanup outcomes without buffering the complete export.
*/
export async function runStreamExport() {
const root = await mkdtemp(join(tmpdir(), 'db3-stream-guide-'));
const disk = new Storage({ disks: { local: { driver: 'local', root } } }).disk('local');
try {
await writeCsvExport(disk, 'exports/latest.csv', { rows: 50_000 });
const sizeBefore = await disk.size('exports/latest.csv');
let interruptionRejected = false;
try { await writeCsvExport(disk, 'exports/latest.csv', { rows: 10, failAt: 2 }); } catch (error) {
// Drivers may wrap failures while preserving the original stream error.
const cause = error instanceof Error && error.cause instanceof Error ? error.cause : error;
if (!(cause instanceof Error) || cause.message !== 'Controlled export interruption') throw error;
interruptionRejected = true;
}
const previousPreserved = await disk.size('exports/latest.csv') === sizeBefore;
const pendingFiles = (await disk.list('pending').toArray()).filter(entry => entry.isFile).length;
await writeCsvExport(disk, 'exports/repaired.csv', { rows: 3 });
let lines = 0;
let bytes = 0;
const sink = new Writable({
/** Counts each chunk without retaining the complete file. */
write(chunk: Buffer, _encoding, callback) { bytes += chunk.length; for (const byte of chunk) if (byte === 10) lines++; callback(); },
});
await pipeline(await disk.readStream('exports/latest.csv'), sink);
return { lines, complete: bytes === sizeBefore, interruptionRejected, previousPreserved, pendingFiles, repaired: await disk.exists('exports/repaired.csv') };
} finally { await rm(root, { recursive: true, force: true }); }
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) console.log(JSON.stringify(await runStreamExport(), null, 2));
Know what the stream does not solve
Local rename and remote object moves do not share an atomicity guarantee. Serialize competing publishers or use immutable versioned destinations plus an application-owned pointer. This test does not certify an S3 provider.
An HTTP adapter must authorize before streaming and stop work on client disconnect. A queue export should be retry-safe and avoid publishing duplicate side effects. This lab proves producer failure cleanup, not network cancellation or crash recovery.
Testing
Create a tests/storage directory and save the test below as runStreamExport.test.ts. It imports the example you copied into examples/. Keep the same folder layout so the relative import resolves.
Run from the application root with the development dependencies from Installation. These are consumer tests, not commands that assume a framework checkout.
import { expect, it } from 'vitest';
import { runStreamExport } from '../../examples/runStreamExport';
it('streams a large export, preserves the previous file on interruption and cleans partial bytes', async () => {
expect(await runStreamExport()).toEqual({ lines: 50_001, complete: true, interruptionRejected: true, previousPreserved: true, pendingFiles: 0, repaired: true });
});
Run and extend the test
Change the row count and move the controlled failure to another row. Keep the old-destination and empty-staging assertions. Test your real driver with a dedicated bucket before relying on its move behavior.
npx vitest run tests/storage/runStreamExport.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.tsCoverage
Tested: real local streaming, complete output, producer failure, previous-file preservation, partial cleanup and retry. Still to build: a socket-disconnect trial, resumable uploads and remote-driver failure/concurrency conformance.
Streams 50,000 CSV rows, interrupts a staged write, preserves the previous export, removes partial bytes and retries.
The guide test passes against the real framework components.packages/app/src/storage/tests/examples/runStreamExport.test.tsThis test command requires the framework repository. Use the walkthrough commands in an installed application.
Environment: Temporary local filesystem, no SQL, HTTP socket or remote provider.