Skip to content

Full migration of Maximizer (legacy desktop CRM, flagged as an unsupported business risk in Tech Stack.md) to a self-hosted Twenty CRM instance. Spun off from Fable-usage — Talbot’s Fable 5 window was used for planning/schema-reverse-engineering; implementation ran on Sonnet 5 per the plan’s own recommendation.

  • Self-hosted Twenty CRM — Docker Compose (~/utils/crm-20/twenty/), reachable at http://localhost:3000, Windows Desktop shortcut + launcher script for one-click access
  • Full data migration: 18,586 people (deduplicated from 18,774), 45 companies, 34,998 notes — all with correct historical dates on both the Notes tab and Timeline tab
  • CASL-critical fields (emailStatus/optInDate/optOutDate) verified intact
  • 4 saved Views: Media Contacts, Advisors, Clients, Prospects - OptedIn
  • Nightly Postgres backup via my_backup (BackupCRM20.py) → d:\FSS\Misc\CRM-20\Bak\twenty-postgres.sql.gz
  • CRM-20 ongoing project registered and scaffolded (~/utils/crm-20/; ETL tool max2twenty/ kept, re-runnable/checkpoint-safe)
  • Open follow-on filed separately: CRM-20-Accessibility (remote access — Cloudflare Tunnel vs. Tailscale, Talbot’s decision pending)

Key discoveries (full detail in ~/utils/crm-20/LESSONS.md)

Section titled “Key discoveries (full detail in ~/utils/crm-20/LESSONS.md)”
  • Twenty’s Core API and Metadata API use completely different mutation conventions (createPerson(data:...) vs createOneField(input:{field:{...}})) — cost real time reverse-engineering via the compiled server JS since GraphQL introspection is disabled for unauthenticated requests.
  • load.py never set createdAt on any note — every one of 34,998 notes got migration load-time as its date instead of its true Maximizer date. Root cause of the whole “Timeline looks wrong” thread. Fixed non-destructively (createdAt is writable via updateNote) rather than requiring a delete+reload.
  • Twenty tracks note history in two places: Note.createdAt (the record itself) and a separate TimelineActivity audit-log object (its own createdAt, joined via linkedRecordId) — the Timeline tab reads the latter. Fixing only the Note wasn’t sufficient; both needed correcting.
  • Maximizer’s bulk-imported historical notes carry two date levels — a bulk-import timestamp in DateCol/TimeCol, and the real date embedded in the note text itself ("LOGS prior to 2014AP ... 11/16/13: ..." or Talbot’s own DDMMMYY shorthand). ~18.5% of notes needed this embedded-date extraction.
  • Twenty’s native person-merge endpoint cascade-deletes the losing record’s notes without re-pointing them first — caught after 5 test merges by comparing raw Postgres table counts (the API response alone didn’t reveal it). Fixed by re-pointing noteTargets to the survivor before calling merge, not after.
  • Saved Views live on a third API surface (/rest/metadata/views + /rest/metadata/viewFilters, plain REST, not GraphQL).

Reusable practice worth considering for global AGENTS.md (surfaced, not yet promoted)

Section titled “Reusable practice worth considering for global AGENTS.md (surfaced, not yet promoted)”

Before/after a mass update or merge on live production data, verify raw row counts in the database directly — not just the API’s response shape. The merge-endpoint bug above would have silently orphaned notes across all 194 duplicate pairs if the first 5 hadn’t been checked against Postgres directly; the GraphQL response looked complete and correct in every case. Talbot: flag if you want this added to the global rules.

MetricCount
People (post-dedupe)18,586
Companies45
Notes34,998
Duplicate pairs merged194 (5 test + 189 batch)
Notes with embedded-date correction6,477
Notes with load-time→original-date correction28,350
TimelineActivity records corrected34,985
Email data-quality exceptions (unfixable typos)148

Full task history (multi-day back-and-forth): Tasks/archive/2026-07/Maximizer-Twenty-CRM-Migration.md