Import thousands of customers with durable per-row progress
Validate a dataset once, process rows concurrently with separate retry identity, upsert valid customers, and return an ordered import report.
A JavaScript loop can transform an array, but it cannot give every row its own durable attempt, progress, cancellation point, and bounded concurrency. <for-each> can.
The workflow at a glance
Validated import webhook
→ Prepare rows
→ For each customer, concurrency 16
→ Normalize
→ Valid? ─ yes → Upsert PostgreSQL
└ no → Record skipped result
→ Summarize ordered resultsThe workflow
<woml>
<workflow
id="customer-import"
name="Customer import"
description="Normalize and persist a batch with durable row-level progress."
version="1.0.0"
>
<config concurrency="2" timeout="30m" queue="imports" />
<triggers>
<webhook
id="importCustomers"
path="/webhooks/customer-imports"
method="POST"
auth="bearer"
secret="{{secrets.IMPORT_WEBHOOK_TOKEN}}"
>
<schema>
{
"type": "object",
"required": ["importId", "customers"],
"properties": {
"importId": { "type": "string" },
"customers": {
"type": "array",
"maxItems": 10000,
"items": {
"type": "object",
"required": ["externalId", "email", "name"],
"properties": {
"externalId": { "type": "string" },
"email": { "type": "string" },
"name": { "type": "string" }
}
}
}
}
}
</schema>
</webhook>
</triggers>
<steps>
<step id="prepareImport" name="Prepare import">
<script>
return {
importId: context.payload.importId,
customers: context.payload.customers
};
</script>
</step>
<for-each
id="processCustomers"
name="Process customers"
items="{{context.steps.prepareImport.customers}}"
concurrency="16"
>
<step id="normalizeCustomer" name="Normalize customer">
<script>
const email = context.item.email.trim().toLowerCase();
const name = context.item.name.trim().replace(/\s+/g, " ");
return {
externalId: context.item.externalId,
email,
name,
valid: /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) && name.length > 0,
row: context.iteration.index
};
</script>
</step>
<choose id="persistCustomer" name="Persist valid customer">
<when test="{{context.steps.normalizeCustomer.valid}}">
<step id="upsertCustomer" name="Upsert customer" retry="3" retry-backoff="exponential">
<script>
const db = services.db({
driver: "postgres",
connection: secrets.POSTGRES_URL
});
await db.execute({
text: `
INSERT INTO customers (external_id, email, name)
VALUES ($1, $2, $3)
ON CONFLICT (external_id)
DO UPDATE SET email = EXCLUDED.email, name = EXCLUDED.name
`,
values: [
context.steps.normalizeCustomer.externalId,
context.steps.normalizeCustomer.email,
context.steps.normalizeCustomer.name
]
}, { name: "upsert-imported-customer" });
return {
row: context.steps.normalizeCustomer.row,
externalId: context.steps.normalizeCustomer.externalId,
status: "imported"
};
</script>
</step>
<result value="{{context.steps.upsertCustomer}}" />
</when>
<otherwise>
<step id="skipCustomer" name="Skip invalid customer">
<script>
return {
row: context.steps.normalizeCustomer.row,
externalId: context.steps.normalizeCustomer.externalId,
status: "invalid"
};
</script>
</step>
<result value="{{context.steps.skipCustomer}}" />
</otherwise>
</choose>
<result value="{{context.steps.persistCustomer}}" />
</for-each>
<step id="summary" name="Build import summary">
<script>
const results = context.steps.processCustomers.results;
return {
importId: context.steps.prepareImport.importId,
total: context.steps.processCustomers.total,
imported: results.filter((row) => row.status === "imported").length,
invalid: results.filter((row) => row.status === "invalid").length,
results
};
</script>
</step>
</steps>
</workflow>
</woml>Run and test it
Create the customers table with a unique external_id, then configure and run WOML:
woml secrets set IMPORT_WEBHOOK_TOKEN
woml secrets set POSTGRES_URL
woml check customer-import.woml
woml run customer-import.womlSend a batch with the authenticated curl command printed by the runtime. Start with a few rows, then increase the batch while watching item progress in woml inspect.
Why this shows WOML's range
The markup exposes data ingress, bounded fan-out, per-row decisions, and aggregation. JavaScript handles normalization, PostgreSQL handles application data, and Rust owns item identity, attempts, recovery, progress, and cancellation. The result remains understandable when the import grows from ten rows to ten thousand.