9.9 KiB
Migrate @suaveplan/contacts
Package: @suaveplan/contacts
Status: PROPOSED
Type: new-package
Design: (none — see specs/contacts/spec.md for normative contract)
Spec delta: ./specs/contacts/spec.md
Why
Contact and group management is a foundation primitive for every channel-bearing surface (emailer, delivery-tracking, notifications, audience selection). Upstream @suaveplan/contacts exists at packages/universal/contacts on main; it is functional but enterprise-incomplete (bare Error everywhere, Date.now() / Math.random() baked into managers, zero OTel instrumentation, no Zod boundary, (store as InMemoryGroupStore).generateId type-launder). This migration ports the full public surface to genesis under the §0a parity rules and lifts it to enterprise quality: structured ContactsError + CONTACTS.* code registry, injectable Clock + IdGenerator, Zod input validation at every public boundary, and a real OTel counters/histograms surface bound through @suaveplan/opentelemetry.
What changes
- ADDED: 23 public exports preserved verbatim from upstream's
src/index.ts(4 manager classes, 1 audience resolver, 3 in-memory stores + 3 store interfaces, 1 exporter, 1 importer + result/error/mapping types, 8 tree utilities + 1 flattened node type, 14 entity / input / query / result types). - ADDED:
CONTACTS_CODESregistry viadefineCodes("CONTACTS", [...])with 14 stable codes (CONTACT_NOT_FOUND,CONTACT_CHANNEL_EXISTS,CONTACT_CHANNEL_EXISTS_ON_OTHER,GROUP_NOT_FOUND,GROUP_PARENT_NOT_FOUND,GROUP_CIRCULAR_REF,GROUP_NEW_PARENT_NOT_FOUND,MEMBERSHIP_EXISTS,MEMBERSHIP_GROUP_NOT_FOUND,MEMBERSHIP_CONTACT_NOT_FOUND,MERGE_PRIMARY_NOT_FOUND,MERGE_SECONDARY_NOT_FOUND,IMPORT_VALIDATION_FAILED,INVALID_INPUT). - ADDED:
ContactsError extends SuaveplanError<ContactsErrorCode>withcontactId/groupId/tenantIddiscriminants. - ADDED: Zod schemas for every public input boundary (
createContactInputSchema,updateContactInputSchema,contactQuerySchema,createGroupInputSchema,updateGroupInputSchema,groupQuerySchema,addMemberInputSchema,audienceResolveOptionsSchema,exportOptionsSchema,importOptionsSchema). - ADDED: OTel instruments bundle (
getContactsInstruments/resetContactsInstruments) exposing six counters (contacts.created_total,contacts.updated_total,contacts.deleted_total,contacts.merged_total,groups.created_total,memberships.added_total) and three histograms (contacts.import_duration_ms,contacts.export_duration_ms,audience.resolve_duration_ms). - ADDED: Injectable
Clock+IdGeneratoronContactManager,GroupManager,MembershipManager,ContactMerger,InMemoryGroupStore,InMemoryMembershipStore—Date.now()/Math.random()/new Date()removed from production paths. - ADDED:
IdGeneratorpromoted toGroupStoreinterface (replaces the brittle(store as InMemoryGroupStore).generateIdcast inGroupManager.create). - MODIFIED: every public manager method validates input via Zod before any side effect; every failure path raises a
ContactsError(no bareError). - MODIFIED:
InMemoryContactStoreaccepts aClock+IdGeneratorinjection; defaults toDate.now+ monotonic counter so callers can wireFakeClockin tests.
Out of scope
- SurrealDB / Postgres store adapters — upstream defers these to
api-plugin-contacts; genesis does the same. - React hooks / UI components — upstream has none; genesis matches.
- Streaming / paging cursors — out of scope at v0.1.0; offset/limit pagination preserved as-is.
Source mapping
This change derives from ../suaveplan/enterprise/contacts.md and the upstream source under ../suaveplan/packages/universal/contacts/src/.
| Requirement | Source lines | Notes |
|---|---|---|
| Public surface preserved (23 exports) | src/index.ts:1-68 |
verbatim |
Contact, ContactChannel, ContactStatus, CreateContactInput, UpdateContactInput, ContactQuery, MergeResult types |
src/types/contact.types.ts:1-76 |
verbatim shape |
Group, CreateGroupInput, UpdateGroupInput, GroupQuery, GroupTreeNode types |
src/types/group.types.ts:1-53 |
verbatim shape |
GroupMembership, AddMemberInput, AudienceResolveOptions, AudienceResult types |
src/types/membership.types.ts:1-38 |
verbatim shape |
ContactStore + InMemoryContactStore (8 methods, channel search, tag/status/search filters, pagination) |
src/stores/contact.store.ts:1-146 |
enhanced with Clock+IdGenerator injection |
GroupStore + InMemoryGroupStore (9 methods, adjacency + materialized-path queries) |
src/stores/group.store.ts:1-98 |
generateId promoted to interface; enhanced with IdGenerator injection |
MembershipStore + InMemoryMembershipStore (9 methods, composite-key store) |
src/stores/membership.store.ts:1-84 |
enhanced with IdGenerator injection |
ContactManager CRUD + channel dedup + tag mgmt + channel add |
src/core/contact-manager/contact-manager.ts:1-142 |
bare throw new Error → ContactsError, Zod boundary, OTel counters |
GroupManager CRUD + hierarchical paths + move + circular-ref guard |
src/core/group-manager/group-manager.ts:1-204 |
bare throw new Error → ContactsError, Zod, OTel; Date.now() replaced with Clock, type-laundered generateId replaced with interface method |
MembershipManager add/remove/bulk/ancestor-counts |
src/core/membership-manager/membership-manager.ts:1-161 |
bare throw new Error → ContactsError, Zod, OTel; Date.now() / Math.random() / new Date() replaced with Clock + IdGenerator |
AudienceResolver resolve + previewCount with descendant walk, exclusions, dedup, channel/status filters |
src/core/audience-resolver/audience-resolver.ts:1-113 |
OTel histogram; Zod boundary |
ContactMerger channel/tag/metadata merge + membership transfer + secondary delete |
src/core/contact-merger/contact-merger.ts:1-125 |
bare throw new Error → ContactsError, Zod, OTel; Date.now() / Math.random() replaced with Clock + IdGenerator |
ContactExporter CSV + JSON formats with field mapping |
src/import-export/contact-exporter.ts:1-82 |
OTel histogram; Zod options validation |
ContactImporter CSV parse + record import with field mapping, default tags, dedup |
src/import-export/contact-importer.ts:1-204 |
bare throw new Error → ContactsError, Zod, OTel histogram |
Tree utilities — buildGroupTree, flattenGroupTree, getAncestorPaths, validateNoCircularRef, countTreeContacts, findInTree, collectAllGroupIds |
src/tree/tree-utils.ts:1-126 |
verbatim algorithms; pure functions |
| Universal-tier purity (no browser / Node-only APIs) | enterprise/contacts.md:L37 |
verbatim |
Injectable Clock + Rng (no Date.now() / Math.random() at module scope) |
enterprise/contacts.md:L40 |
verbatim |
OTel instrumentation via @suaveplan/opentelemetry |
enterprise/contacts.md:L41 |
verbatim |
Structured errors extending @suaveplan/error with stable codes |
enterprise/contacts.md:L42 |
verbatim |
| Zod schemas at every public boundary | enterprise/contacts.md:L38 |
verbatim |
100% Istanbul coverage, no it.skip, no any, no ts-ignore |
enterprise/contacts.md:L50-L60 |
verbatim |
Requirements with no source line
| Requirement | Justification |
|---|---|
INVALID_INPUT error code |
Genesis-wide policy: Zod boundary failures raise a typed error, not throw the raw ZodError. |
GROUP_NEW_PARENT_NOT_FOUND distinct from GROUP_PARENT_NOT_FOUND |
Allows consumers to dispatch differently on "create with missing parent" vs "move to missing parent". |
IdGenerator injection on store + manager |
Genesis-wide rule: no Date.now() / Math.random() for ID generation in production paths. Replaces upstream membership_${Date.now()}_${Math.random()...} pattern. |
Clock injection on every entity-creating manager |
Same — genesis-wide policy on temporal determinism. |
Verification approach
src/error-codes/error-codes.test.ts— registry shape, every code constructiblesrc/schemas/schemas.test.ts— every schema accepts canonical input, rejects malformed input withZodErrorsrc/otel/instruments.test.ts— instruments observable via in-memoryMeterProvidersrc/stores/contact-store/contact-store.test.ts,src/stores/group-store/group-store.test.ts,src/stores/membership-store/membership-store.test.ts— every interface method + invariantssrc/core/contact-manager/contact-manager.test.ts— CRUD, dedup, tag/channel ops, OTel emission, error mappingsrc/core/group-manager/group-manager.test.ts— create/update/delete/move with paths and depth, circular-ref guard, OTelsrc/core/membership-manager/membership-manager.test.ts— add/remove, bulk ops swallow duplicates, ancestor count propagation, OTelsrc/core/contact-merger/contact-merger.test.ts— channel/tag/metadata merge, membership transfer, secondary deletion, OTelsrc/core/audience-resolver/audience-resolver.test.ts— flat + nested + dedup + exclusions + channel/status filter + previewCount, OTelsrc/tree/tree-utils.test.ts— every pure tree helpersrc/import-export/contact-exporter.test.ts,src/import-export/contact-importer.test.ts— CSV/JSON round-trips, field mapping, default tags, dedup, error rowssrc/contacts.spec-conformance.test.ts— tier purity, barrel shape, subpath exports, error-code → call-site mapping, OTel instruments wired
Migration impact
None — additive only. Genesis branch is empty; no consumer exists yet.
Impact
Affected dependents
bunx turbo ls --filter=...@suaveplan/contacts returns no dependents on genesis (no consumer has been migrated yet). When dependents land (notifications, audience-selection, emailer), they will add @suaveplan/contacts as a workspace:^ dep and consume the public surface; the spec-conformance test pins the surface so dependents don't break silently.
No dependents on genesis (verified by bunx turbo ls --filter=...@suaveplan/contacts).