Recipes
Three jobs Sarah actually needed — syncing from her old system, finding people entered twice, and tagging everyone at once.
Whole jobs rather than individual endpoints. Each assumes the api() helper from
Server apps and the pages() generator from
Long lists.
Keep customers in step with another system
Sarah's old scheduling software is still the place her office adds customers. She wants them copied across nightly until she retires it.
The catch: email is unique per site, so a naive "add everyone" run works once and then fails on every subsequent night. You want an upsert, and there's no upsert endpoint — so look them up first.
A nightly sync doesn't belong in a browser. It needs to finish without a tab staying open, and it needs write scopes you shouldn't ship to a browser.
What the browser should do is start it and show progress:
const { jobId } = await fetch('/internal/sync-customers', { method: 'POST' })
.then((r) => r.json());
const source = new EventSource(`/internal/sync-customers/${jobId}/progress`);
source.addEventListener('progress', (event) => {
const { done, total } = JSON.parse(event.data);
setProgress(done / total);
});Run the sync itself on your server, with the code in the Server tab.
For the first load of three hundred, use an import instead and keep this for the nightly difference.
Find people entered twice
Sarah suspects the Hendersons are in there twice — once from a phone enquiry, once from the website, with different email addresses. Nothing prevents that: uniqueness is on email, not on people.
function normalisePhone(phone) {
return (phone ?? '').replace(/\D/g, '').slice(-10);
}
async function findDuplicates() {
const byName = new Map();
const byPhone = new Map();
for await (const batch of pages('/api/modules/clients')) {
for (const customer of batch) {
const nameKey = `${customer.first_name}|${customer.last_name}`.toLowerCase();
const phoneKey = normalisePhone(customer.phone);
if (nameKey.length > 1) {
byName.set(nameKey, [...(byName.get(nameKey) ?? []), customer]);
}
if (phoneKey.length === 10) {
byPhone.set(phoneKey, [...(byPhone.get(phoneKey) ?? []), customer]);
}
}
}
const groups = [...byName.values(), ...byPhone.values()].filter((g) => g.length > 1);
// De-duplicate the groups themselves — a pair matching on both name and
// phone would otherwise appear twice.
const seen = new Set();
return groups.filter((group) => {
const key = group.map((c) => c.id).sort().join(',');
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}Report them, don't merge them automatically
There's no merge endpoint, and writing one means deciding whose visits, invoices and notes survive. Two people genuinely do share a name — Sarah has two Dave Wilsons.
Show candidates to a human. When one's confirmed, move the notes and tags onto the keeper and delete the other, which stays reversible.
This walks the whole list, so run it overnight and cache the result rather than recomputing it on every page load.
Tag every existing customer
Sarah's starting a loyalty scheme and wants everyone already on the books marked, before she starts tagging new arrivals differently.
const TAG = {
name: 'Founding customer',
icon: 'star',
color: '#00a87e',
description: 'With us before the loyalty scheme started.',
};
async function tagEveryone() {
let tagged = 0;
let skipped = 0;
for await (const batch of pages('/api/modules/clients')) {
for (const customer of batch) {
// Tags arrive expanded on the customer, so no extra read needed.
if (customer.traits?.some((tag) => tag.name === TAG.name)) {
skipped += 1;
continue;
}
await api(`/api/modules/clients/${customer.id}/traits`, {
method: 'POST',
headers: { 'Idempotency-Key': `founding-${customer.id}` },
body: JSON.stringify(TAG),
});
tagged += 1;
}
}
console.log(`Tagged ${tagged}, already done ${skipped}`);
}Checking customer.traits first is what makes this safe to run again — and you will run it
again, because a partial failure halfway through three hundred customers is the normal
outcome rather than the exceptional one. The Idempotency-Key covers the narrower case of one
request being retried.
Remember tags store their own icon and colour per customer, which is why the TAG constant
exists. Change the styling later and you're making another pass over all of them; if the
vocabulary is genuinely fixed and shared,
statuses are the better fit.