Skip to content

Commit d01f74c

Browse files
mpstatonclaude
andcommitted
fix(crm-starter-export): importer survives contact with reality — rate pacing, no company xLink, domain-dupe retry, attach-repair
Three live-run findings fixed: - Twenty rate-limits at 100 req/60s (77 people 429'd on run one) — every call is paced at 650ms with a 61s sit-out on 429; idempotency made the retries free. - This instance's company object has NO xLink field (8 companies 400'd) — X URLs stay in augment-it. - The Koch siblings share standtogether.org and tripped duplicate detection — duplicate-on-domain retries without domainName; the slug is the identity that matters. Plus an attach-repair pass: people created while their company's create had failed get companyId filled ONLY when null — additive, never clobbering an attach made in the app. Final state: 82 companies + 108 people live in reach-edu's Twenty, 15 attach-repairs, zero failures on the final pass. Files changed: - scripts/import-crm-starter-to-twenty.mjs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW28dw3kQAKXr2ZNefCukE
1 parent 4ae2d0a commit d01f74c

1 file changed

Lines changed: 55 additions & 14 deletions

File tree

scripts/import-crm-starter-to-twenty.mjs

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -58,19 +58,35 @@ function readKey() {
5858
}
5959
const KEY = readKey();
6060

61+
// Twenty rate-limits at 100 requests / 60s (observed live 2026-07-28: the
62+
// first import run 429'd 77 people). Pace every call under the ceiling and
63+
// sit out the window on 429 — idempotency makes retries safe.
64+
const PACE_MS = 650;
65+
let lastCall = 0;
66+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6167
async function api(method, path, body) {
62-
const res = await fetch(`${args.baseUrl}${path}`, {
63-
method,
64-
headers: {
65-
Authorization: `Bearer ${KEY}`,
66-
'Content-Type': 'application/json',
67-
},
68-
body: body ? JSON.stringify(body) : undefined,
69-
});
70-
const text = await res.text();
71-
let json = null;
72-
try { json = JSON.parse(text); } catch { /* leave null */ }
73-
return { status: res.status, json, text };
68+
for (let attempt = 0; attempt < 4; attempt += 1) {
69+
const wait = lastCall + PACE_MS - Date.now();
70+
if (wait > 0) await sleep(wait);
71+
lastCall = Date.now();
72+
const res = await fetch(`${args.baseUrl}${path}`, {
73+
method,
74+
headers: {
75+
Authorization: `Bearer ${KEY}`,
76+
'Content-Type': 'application/json',
77+
},
78+
body: body ? JSON.stringify(body) : undefined,
79+
});
80+
const text = await res.text();
81+
let json = null;
82+
try { json = JSON.parse(text); } catch { /* leave null */ }
83+
if (res.status === 429 && attempt < 3) {
84+
console.log(` … rate-limited, sitting out 61s (attempt ${attempt + 1})`);
85+
await sleep(61_000);
86+
continue;
87+
}
88+
return { status: res.status, json, text };
89+
}
7490
}
7591

7692
// ---- csv --------------------------------------------------------------------
@@ -113,7 +129,8 @@ for (const r of orgRows) {
113129
name: r.name || r.external_id,
114130
domainName: domain ? { primaryLinkUrl: `https://${domain}` } : undefined,
115131
linkedinLink: r.linkedin ? { primaryLinkUrl: r.linkedin } : undefined,
116-
xLink: r.x ? { primaryLinkUrl: r.x } : undefined,
132+
// NO xLink: this instance's company object has no such field (observed
133+
// live 2026-07-28, 400s on 8 companies). X URLs stay in augment-it.
117134
augmentItSlug: r.external_id,
118135
});
119136
}
@@ -271,7 +288,15 @@ const slugToId = new Map([...liveCompanyBySlug.entries()].map(([s, c]) => [s, c.
271288
let created = 0, failed = 0;
272289
for (const c of newCompanies) {
273290
const body = Object.fromEntries(Object.entries(c).filter(([, v]) => v !== undefined));
274-
const r = await api('POST', '/rest/companies', body);
291+
let r = await api('POST', '/rest/companies', body);
292+
// Domain collisions (the Koch siblings share standtogether.org) trip
293+
// Twenty's duplicate detection — retry without the domain; the slug is
294+
// the identity that matters.
295+
if (r.status === 400 && /duplicate/i.test(r.text) && body.domainName) {
296+
const { domainName, ...noDomain } = body;
297+
console.log(` … ${c.augmentItSlug}: duplicate on domain, retrying without domainName`);
298+
r = await api('POST', '/rest/companies', noDomain);
299+
}
275300
const rec = r.json?.data?.createCompany ?? r.json?.data ?? null;
276301
if (r.status < 300 && rec?.id) {
277302
slugToId.set(c.augmentItSlug, rec.id);
@@ -302,6 +327,22 @@ for (const p of newPeople) {
302327
}
303328
}
304329
console.log(`people: created ${pCreated}, failed ${pFailed}, pre-existing ${people.length - newPeople.length}, attach-misses ${pUnattached}`);
330+
331+
// 5. Attach-repair: people created on an earlier run while their company's
332+
// create had failed sit with companyId null. Fill it ONLY when null —
333+
// additive, never clobbers an attach someone made in the app.
334+
let repaired = 0;
335+
for (const p of people) {
336+
if (!p._org_slug) continue;
337+
const live = livePersonByUuid.get(p.augmentItPersonUuid);
338+
if (!live || live.companyId) continue;
339+
const companyId = slugToId.get(p._org_slug) ?? liveCompanyByName.get((p._org_name ?? '').toLowerCase())?.id;
340+
if (!companyId) continue;
341+
const r = await api('PATCH', `/rest/people/${live.id}`, { companyId });
342+
if (r.status < 300) repaired += 1;
343+
else console.log(` ✗ attach-repair ${p.name.firstName} ${p.name.lastName}: ${r.status} ${r.text.slice(0, 120)}`);
344+
}
345+
if (repaired) console.log(`attach-repair: ${repaired} people gained their company (null-only fill)`);
305346
console.log('\nVERIFY: spot-check five companies for link fidelity, three multi-affiliation people, then re-run this script — it should report 0 to create (the external-id round-trip proof).');
306347
if (args.withOpportunities) {
307348
console.log('⚠ --with-opportunities: NOT implemented until the operator rules opportunities-vs-company-fields at dry-run review.');

0 commit comments

Comments
 (0)