Skip to content

fix(atlas): records --data reads a path and fails loudly (#193517); feat(leads): title/external_id/product/segment/name_verified + leads import (#193510–#193514) - #584

Merged
mayoalexander merged 4 commits into
mainfrom
wt/193517-leads-import
Nov 14, 2026
Merged

mayoalexander merged 4 commits into
mainfrom
wt/193517-leads-import

Conversation

@mayoalexander

Copy link
Copy Markdown

Atlas tickets: #193517 (records upsert "Done" on failure), #193510 (title), #193511 (external id), #193512 (product/segment), #193513 (bulk import, CLI side), #193514 (partly hidden names) · parent #193509

Base is main because platform commands (packages/opencode/src/cli/cmd/) reach the CLI release from main via sync-iris-v1.ts.

1 · #193517: atlas:datasets records --data <path>

  • records upsert --data <path> read the path as if it were JSON, printed "Invalid JSON", then "Done", and exited 0. add read a path only when it ended in .json. update never read a path at all.
  • All three now go through one loader, parseDataArg. It takes inline JSON, an existing file, @file, or ~/ paths. Reading a file uses loadJsonFile, which import uses too. The value must be a JSON object.
  • Every failure in records add/update/upsert/delete sets exit 1 and prints "Failed", never "Done". That covers auth, parse errors, API errors and thrown errors. import now also exits 1 when its file is missing, when it can't be parsed, when a chunk fails, or when any row is rejected.

2 · Lead fields + iris leads import

  • leads create / leads update: new flags --title --external-id --product (iris-terra|iris-kinetic|iris-orbit|iris) --segment --name-verified/--no-name-verified.
    • Only flags you pass are sent.
    • The saved lead is read back. If the server dropped a field it was sent, that's an error and the exit code is 1. This matters until the backend ships.
    • On create, a name containing * is sent with name_verified=false.
  • leads list: new filters --title --product --segment --external-id --unverified-names (sent as name_verified=0).
    • The table now shows title, product, and "(name not verified)". leads get shows Title, Product, Segment, External ID and the not-verified warning.
    • A field filter turns off the hide-Prospected default, because imported leads may be Prospected.
    • Guard: if the server returns rows that don't match a filter (an old server ignores unknown params), those rows are dropped and a warning names the ignored filter. Without it, --product iris-kinetic would list everyone.
  • NEW leads import <file.csv|file.json> --board <id> [--dry-run] [--json] [--map col=field] [--set field=value] [--external-id-prefix p]:
    • Uses an RFC 4180 CSV parser (handles quoted commas, quotes and newlines). JSON can be an array, {leads|records|data:[…]}, or an Atlas {external_id,data} export.
    • A column named like a lead field maps to it automatically. --map overrides that, and --set gives constant values. Columns that aren't imported are listed.
    • Rows are sent in chunks of ≤1000 to POST /api/v1/leads/import. Output: created / merged / failed, with file row and line numbers.
    • It stops at the first refused chunk and won't press on. It also stops if the server's dry_run doesn't match the request, or if created+merged+failed doesn't equal the rows sent.
    • Exits 1 if any row failed, any chunk failed, or nothing could be imported.
    • Dry run sends dry_run=true and prints "DRY RUN — nothing was written".
    • Apollo rows: first_name + last_name_hint → name, a hint containing * → name_verified=false, apollo_id → external_id apollo:<id> (the key the product-leads dataset uses), terra → iris-terra. A masked name is never sent as verified, whatever a column says.
    • It warns when a file has no external_id column, because a re-run would then duplicate.

How it was verified

  • bun test across the leads, atlas-dataset and atlas suites plus test/cli/atlas-json-error-reason: 342 pass, 0 fail, on the merge with origin/main at 80adef0552. tsgo --noEmit is clean.
  • New tests:
    • atlas-records-data-arg.test.ts (9): --data <path> and inline JSON work. Bad JSON, a bad file, a missing file and an API 500 each exit 1, send nothing, and never print "Done". add and update are covered the same way.
    • leads-import.test.ts (22): parser, mapping, Apollo rules, flags, filters and the response shape. Commands run through the real yargs tree with fetch stubbed:
      • a dry run
      • 20 rows imported twice end as 20 (the second run merges)
      • 2,500 rows go as 1000/1000/500, and failures map back to file rows 1/1001/2001
      • a 404 stops the run
      • create, update and list send the new fields; create flags a field the server dropped; list drops rows that don't match the filter
  • TYPED fake (#189389): the server half of this contract is being built in parallel and isn't deployed. Every HTTP test answers from a fake I wrote from the agreed contract, so it shows the client matches the contract as I read it, not that the server does. The round trip is unverified until the backend PR is live.
  • Real input, parsed locally only (nothing sent): ~/marketing/leads-2026-11-14/terra-leads.csv gave 400 rows → 400 leads, 0 refused. 387 were marked name-not-verified (this matches a Python count of * in last_name_hint). All 400 have apollo:<id> external ids, all unique; all are iris-terra; all have a title.

Contract points I guessed (backend please confirm)

  1. The import response may come bare or wrapped in {data:…}. Both are accepted.
  2. failed[].index is assumed to be the 0-based index within that request's leads array.
  3. ?name_verified=0 for false. Laravel's boolean rule rejects the string "false".
  4. ?title= is checked client-side as a case-insensitive substring. If the server does exact match, that's fine; if it matches more loosely, the guard will drop rows.
  5. The order of ids isn't specified. The CLI passes them through and doesn't map them to rows.

Not merged, not released. A person merges.

🤖 Generated with Claude Code

mayoalexander and others added 4 commits November 14, 2026 03:23
…exits non-zero (#193517)

upsert fed the path to JSON.parse, printed 'Invalid JSON', then 'Done' and exited 0.
add read a path only if it ended in .json; update never did. All three now use one
loader (shared with import), and auth/API/parse failures set exit 1 and print 'Failed'.
import also exits non-zero when any row is rejected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…is leads import (#193510 #193511 #193512 #193513 #193514)

- leads create/update: --title --external-id --product --segment --name-verified/--no-name-verified;
  the saved lead is read back and a field the server dropped is an error (exit 1).
- leads list: --title --product --segment --external-id --unverified-names; title and product
  in the table. Rows a server returns without applying a filter are dropped and reported.
- leads import <csv|json> --board <id> [--dry-run] [--json] [--map col=field] [--set field=value]:
  chunks of 1000 to POST /api/v1/leads/import, totals with file row numbers for failures,
  non-zero exit on any failure. Apollo rows: first_name + last_name_hint -> name, masked hint
  -> name_verified=false, apollo_id -> external_id apollo:<id>, terra -> iris-terra.

Server half is in progress; HTTP tests use a TYPED fake of the agreed contract.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@mayoalexander
mayoalexander merged commit 712dd0e into main Nov 14, 2026
2 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant