Ops teams import customer lists, price sheets and backfills from spreadsheets every week. Here is a tested TypeScript import tool with direct uploads to S3, a background worker and a status endpoint, built by a coding agent and deployed to your own AWS account.
Most internal tools end up with an import button. Sales has a spreadsheet of new accounts, finance has a price list, and someone needs to backfill a table from an export. A coding agent writes the CSV parser in a minute, and the rest of the tool is the part that breaks in production: files too large for a request body, imports that time out halfway, and retries that insert every row twice.
This guide builds a customer import tool with a coding agent: the browser uploads the file directly to object storage, a background worker parses it and upserts the rows in a transaction, and a status endpoint reports the result. The walkthrough uses Encore as a TypeScript service for the infrastructure, and the code was tested with encore run (Encore v1.58.8). After that comes the same design built by hand with S3, SQS and Lambda.
Encore combines an open source infrastructure SDK for TypeScript and Go with a platform that provisions what the code declares in your own AWS or GCP account. The agent declares the bucket, the topic and the database next to the endpoints that use them, encore run runs all three locally, and a deploy creates S3, SNS/SQS and RDS in your account.

The prompt that produced the code below:
Add a customer CSV import to the tools service. The browser should upload straight to an Encore bucket with a signed URL. After upload, queue the import on a Pub/Sub topic. The subscriber reads the file, checks the header is
email,name,plan, upserts customers by email in one transaction, and records status and row count. Add a status endpoint.
-- tools/migrations/1_create_tables.up.sql
CREATE TABLE customers (
email TEXT PRIMARY KEY,
name TEXT NOT NULL,
plan TEXT NOT NULL
);
CREATE TABLE imports (
key TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'pending',
rows_imported INTEGER NOT NULL DEFAULT 0,
error TEXT
);
tools/db.ts declares the database with new SQLDatabase("tools", { migrations: "./migrations" }), and Encore applies the migration on startup locally and before each deploy.
// tools/imports.ts
import { api, APIError } from "encore.dev/api";
import { Topic, Subscription } from "encore.dev/pubsub";
import { Bucket } from "encore.dev/storage/objects";
import { randomUUID } from "node:crypto";
import { db } from "./db";
// Uploaded CSV files: a local bucket under `encore run`, S3 on AWS.
const uploads = new Bucket("csv-uploads");
interface ImportRequested {
key: string;
}
const importRequested = new Topic<ImportRequested>("import-requested", {
deliveryGuarantee: "at-least-once",
});
// Step 1: the browser asks for an upload URL and PUTs the file straight to the bucket.
export const startUpload = api(
{ expose: true, method: "POST", path: "/imports" },
async (): Promise<{ key: string; uploadUrl: string }> => {
const key = `customers/${randomUUID()}.csv`;
const { url } = await uploads.signedUploadUrl(key, { ttl: 600 });
await db.exec`INSERT INTO imports (key) VALUES (${key})`;
return { key, uploadUrl: url };
},
);
// Step 2: once the upload is done, the browser asks for the import to run.
export const runImport = api(
{ expose: true, method: "POST", path: "/imports/run" },
async ({ key }: { key: string }): Promise<{ queued: boolean }> => {
if (!(await uploads.exists(key))) throw APIError.notFound("upload not found");
await importRequested.publish({ key });
return { queued: true };
},
);
export const importStatus = api(
{ expose: true, method: "GET", path: "/imports/status" },
async ({ key }: { key: string }): Promise<{ status: string; rowsImported: number; error: string | null }> => {
const row = await db.queryRow<{ status: string; rowsImported: number; error: string | null }>`
SELECT status, rows_imported AS "rowsImported", error FROM imports WHERE key = ${key}`;
if (!row) throw APIError.notFound("import not found");
return row;
},
);
The signed URL expires after ten minutes and is scoped to one key: the browser can write that file and nothing else. The object storage docs cover signed URLs and the other bucket methods.
The subscriber runs the import outside the request, so a 50,000-row file does not hold an HTTP connection open:
// tools/imports.ts (continued)
// Step 3: a subscriber parses the file and upserts rows, so a redelivery
// writes the same rows again instead of duplicating them.
const _ = new Subscription(importRequested, "import-customers", {
handler: async ({ key }) => {
const text = (await uploads.download(key)).toString("utf8");
const [header, ...lines] = text.trim().split(/\r?\n/);
if (header.trim() !== "email,name,plan") {
await db.exec`UPDATE imports SET status = 'failed', error = 'expected header email,name,plan' WHERE key = ${key}`;
return;
}
await using tx = await db.begin();
let count = 0;
for (const line of lines) {
const [email, name, plan] = line.split(",").map((s) => s.trim());
if (!email?.includes("@")) continue;
await tx.exec`
INSERT INTO customers (email, name, plan) VALUES (${email}, ${name}, ${plan})
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name, plan = EXCLUDED.plan`;
count++;
}
await tx.exec`UPDATE imports SET status = 'done', rows_imported = ${count} WHERE key = ${key}`;
await tx.commit();
},
});
Pub/Sub delivers at least once, which is why the rows are written with ON CONFLICT and the whole file runs in one transaction. A crash halfway rolls back, the message is redelivered, and the import runs again from the start. The split on commas fits this fixed three-column format; for files with quoted fields, ask the agent to use a CSV parsing library instead.
Running the import a second time leaves two customers, and POST /imports/run with a key that was never uploaded returns 404. The local development dashboard shows the publish and the subscriber run in the trace for each request.
Connect your AWS account and push. Encore creates the S3 bucket, an SNS topic with an SQS queue for the subscription, and the RDS database in your own account, and Encore provisions an IAM role for the service. If the upload page is served from another origin, add it to global_cors in encore.app as described in the CORS docs, and set CORS on the bucket for the browser's PUT. A frontend can call the endpoints through a typed client from encore gen client.

Keep the imports row as the source of truth for status, so a page can poll it and show errors to the person who uploaded the file. For very large files, the worker can stream the object and commit in batches, with the upsert keeping each batch safe to replay.
The same design without Encore is a set of resources someone provisions and connects:
PUT URLs, as in the S3 presigned URL docs.An agent can write the Terraform for all of it, and every file is one more thing to review, apply and keep in sync with the application code.
encore run and deployed with their bucket, queue and database to your own AWS account.Ask for three endpoints and a worker: one that returns a signed upload URL, one that queues the import once the file is uploaded, one that reports status, and a subscriber that parses the file and upserts rows in a transaction. With Encore, the agent declares the bucket, topic and database in TypeScript, tests the full flow with encore run, and on deploy Encore provisions S3, SNS/SQS and RDS in your AWS account.
A signed upload URL lets the browser send the file straight to the bucket, so large files do not pass through the API process or hit request size limits, and the API only handles small JSON requests.
Write rows with an upsert keyed on a natural identifier such as the email address, and run the whole file in one transaction. A redelivered message then writes the same rows again instead of duplicating them, and a failure halfway leaves no partial import.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.