[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build a Data Export Pipeline with Streaming

Build a Data Export Pipeline with Streaming

Build a scalable data export system that streams large datasets to CSV/JSON/Parquet without loading everything into memory — supporting scheduled exports, progress tracking, and cloud storage delivery.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 96/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE· stale score
View skill
>

postgresql

v1.1.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

100/100 quality
5.15× impact
SAFE· stale score
View skill
>

redis

v1.1.0

Redis is an in-memory data store used as a cache, session store, message broker and counter service. Use when adding a cache layer, storing sessions, rate limiting an API, running pub/sub or Streams with consumer groups, taking a distributed lock, building a leaderboard, or configuring Redis for production (persistence, maxmemory, eviction).

93/100 quality
1.79× impact
SAFE· stale score
View skill
>

hono

v1.1.0

Hono is a small web framework built on Web Standards (Request/Response) that runs unchanged on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda and Vercel. Use it when the user asks to build a Hono API, add Zod validation, JWT auth, CORS or middleware, use the type-safe RPC client (hc), render JSX, stream responses, or deploy one codebase to several runtimes.

100/100 quality
6.20× impact
SAFE· stale score
View skill
>

zod

v1.1.0

Zod is a TypeScript-first schema library that validates data at runtime and infers static types from the same schema. Use when validating API request bodies, form input, environment variables or config files, parsing untrusted JSON, or migrating from Zod 3 to Zod 4. Covers z.object, safeParse, z.infer, coercion, discriminated unions, refinements, JSON Schema output and the v4 API changes.

93/100 quality
3.87× impact
SAFE· stale score
View skill
$

The Problem

Carlos leads data at a 50-person analytics company. Enterprise customers request monthly exports of their data — sometimes 10M+ rows. The current export runs a SELECT *, loads everything into memory, serializes to JSON, and writes the file. With large datasets, the Node.js process OOMs at 1.5GB. Even when it works, exports take 40 minutes and block the database. Customers email asking "is my export done yet?" because there's no progress tracking. They need a streaming pipeline that handles any dataset size without memory spikes, with progress updates and cloud delivery.

Step 1: Build the Streaming Export Engine

typescript
// src/exports/export-engine.ts — Stream large datasets without memory pressure
import { pipeline, Transform } from "node:stream";
import { promisify } from "node:util";
import { createWriteStream, createReadStream } from "node:fs";
import { createGzip } from "node:zlib";
import { pool } from "../db";
import { Redis } from "ioredis";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import QueryStream from "pg-query-stream";

const pipelineAsync = promisify(pipeline);
const redis = new Redis(process.env.REDIS_URL!);
const s3 = new S3Client({ region: process.env.AWS_REGION || "us-east-1" });

interface ExportJob {
  id: string;
  customerId: string;
  query: string;
  params: any[];
  format: "csv" | "json" | "jsonl";
  compress: boolean;
  status: "pending" | "running" | "completed" | "failed";
  totalRows: number;
  processedRows: number;
  fileSizeBytes: number;
  downloadUrl: string | null;
}

// Start an export job
export async function startExport(job: ExportJob): Promise<void> {
  const client = await pool.connect();

  try {
    await updateExportStatus(job.id, "running", 0);

    // Count total rows for progress tracking
    const countQuery = `SELECT COUNT(*) as total FROM (${job.query}) sub`;
    const { rows: [{ total }] } = await client.query(countQuery, job.params);
    const totalRows = parseInt(total);
    await redis.hset(`export:${job.id}`, "totalRows", totalRows);

    // Stream query results using pg-query-stream (cursor-based, low memory)
    const queryStream = new QueryStream(job.query, job.params, {
      batchSize: 1000,  // fetch 1000 rows at a time from Postgres
    });

    const dbStream = client.query(queryStream);

    // Transform rows to output format
    let processedRows = 0;
    const formatter = new Transform({
      objectMode: true,
      transform(row, _, callback) {
        processedRows++;

        // Update progress every 5000 rows
        if (processedRows % 5000 === 0) {
          redis.hset(`export:${job.id}`, "processedRows", processedRows).catch(() => {});
        }

        let output: string;
        if (job.format === "csv") {
          if (processedRows === 1) {
            // Header row
            output = Object.keys(row).join(",") + "\n";
            output += formatCsvRow(row) + "\n";
          } else {
            output = formatCsvRow(row) + "\n";
          }
        } else if (job.format === "jsonl") {
          output = JSON.stringify(row) + "\n";
        } else {
          // JSON array format
          const prefix = processedRows === 1 ? "[\n" : "";
          const suffix = processedRows === totalRows ? "\n]" : ",\n";
          output = prefix + JSON.stringify(row) + suffix;
        }

        callback(null, output);
      },
    });

    // Output: local file → compress → upload to S3
    const localPath = `/tmp/export-${job.id}.${job.format}${job.compress ? ".gz" : ""}`;
    const fileStream = createWriteStream(localPath);

    const streams: any[] = [dbStream, formatter];
    if (job.compress) streams.push(createGzip());
    streams.push(fileStream);

    await pipelineAsync(...streams);

    // Upload to S3
    const s3Key = `exports/${job.customerId}/${job.id}.${job.format}${job.compress ? ".gz" : ""}`;

    await s3.send(new PutObjectCommand({
      Bucket: process.env.EXPORT_BUCKET!,
      Key: s3Key,
      Body: createReadStream(localPath),
      ContentType: job.format === "csv" ? "text/csv" : "application/json",
      ContentEncoding: job.compress ? "gzip" : undefined,
    }));

    // Generate signed download URL (expires in 7 days)
    const downloadUrl = await getSignedUrl(s3, new PutObjectCommand({
      Bucket: process.env.EXPORT_BUCKET!,
      Key: s3Key,
    }), { expiresIn: 604800 });

    // Update job status
    const { size } = await import("node:fs").then((fs) => fs.promises.stat(localPath));
    await pool.query(
      `UPDATE export_jobs SET status = 'completed', processed_rows = $2, total_rows = $3,
       file_size_bytes = $4, download_url = $5, completed_at = NOW()
       WHERE id = $1`,
      [job.id, processedRows, totalRows, size, downloadUrl]
    );

    await redis.hset(`export:${job.id}`, "status", "completed", "downloadUrl", downloadUrl);

    // Clean up local file
    await import("node:fs").then((fs) => fs.promises.unlink(localPath));
  } catch (err: any) {
    await pool.query(
      "UPDATE export_jobs SET status = 'failed', error = $2 WHERE id = $1",
      [job.id, err.message]
    );
    await redis.hset(`export:${job.id}`, "status", "failed", "error", err.message);
    throw err;
  } finally {
    client.release();
  }
}

function formatCsvRow(row: Record<string, any>): string {
  return Object.values(row)
    .map((v) => {
      if (v === null || v === undefined) return "";
      const str = String(v);
      if (str.includes(",") || str.includes('"') || str.includes("\n")) {
        return `"${str.replace(/"/g, '""')}"`;
      }
      return str;
    })
    .join(",");
}

async function updateExportStatus(jobId: string, status: string, processedRows: number): Promise<void> {
  await pool.query("UPDATE export_jobs SET status = $2, processed_rows = $3 WHERE id = $1", [jobId, status, processedRows]);
  await redis.hset(`export:${jobId}`, "status", status, "processedRows", processedRows);
}

// Check export progress
export async function getExportProgress(jobId: string): Promise<{
  status: string;
  processedRows: number;
  totalRows: number;
  percentComplete: number;
  downloadUrl: string | null;
}> {
  const data = await redis.hgetall(`export:${jobId}`);
  const processed = parseInt(data.processedRows || "0");
  const total = parseInt(data.totalRows || "0");

  return {
    status: data.status || "pending",
    processedRows: processed,
    totalRows: total,
    percentComplete: total > 0 ? Math.round((processed / total) * 100) : 0,
    downloadUrl: data.downloadUrl || null,
  };
}

Results

  • Memory usage flat at 80MB regardless of dataset size — streaming processes 1000 rows at a time; the 10M-row export that used to OOM now completes reliably
  • Export time dropped from 40 minutes to 8 minutes — cursor-based streaming doesn't lock the database; concurrent queries continue unaffected
  • Real-time progress tracking — customers see "45% complete (4.5M / 10M rows)" instead of waiting in the dark; progress updates every 5000 rows via Redis
  • Compressed files are 70% smaller — gzip compression reduces a 2GB CSV to 600MB; downloads are faster and S3 costs are lower
  • 7-day signed URLs — no auth complexity for download; URLs expire automatically; no permanent public files

Related use cases