Files
suaveplan/openspec/archive/2026-05-20-migrate-contacts/proposal.md
2026-05-27 23:29:53 +01:00

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_CODES registry via defineCodes("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> with contactId / groupId / tenantId discriminants.
  • 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 + IdGenerator on ContactManager, GroupManager, MembershipManager, ContactMerger, InMemoryGroupStore, InMemoryMembershipStore — Date.now() / Math.random() / new Date() removed from production paths.
  • ADDED: IdGenerator promoted to GroupStore interface (replaces the brittle (store as InMemoryGroupStore).generateId cast in GroupManager.create).
  • MODIFIED: every public manager method validates input via Zod before any side effect; every failure path raises a ContactsError (no bare Error).
  • MODIFIED: InMemoryContactStore accepts a Clock + IdGenerator injection; defaults to Date.now + monotonic counter so callers can wire FakeClock in 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 constructible
  • src/schemas/schemas.test.ts — every schema accepts canonical input, rejects malformed input with ZodError
  • src/otel/instruments.test.ts — instruments observable via in-memory MeterProvider
  • src/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 + invariants
  • src/core/contact-manager/contact-manager.test.ts — CRUD, dedup, tag/channel ops, OTel emission, error mapping
  • src/core/group-manager/group-manager.test.ts — create/update/delete/move with paths and depth, circular-ref guard, OTel
  • src/core/membership-manager/membership-manager.test.ts — add/remove, bulk ops swallow duplicates, ancestor count propagation, OTel
  • src/core/contact-merger/contact-merger.test.ts — channel/tag/metadata merge, membership transfer, secondary deletion, OTel
  • src/core/audience-resolver/audience-resolver.test.ts — flat + nested + dedup + exclusions + channel/status filter + previewCount, OTel
  • src/tree/tree-utils.test.ts — every pure tree helper
  • src/import-export/contact-exporter.test.ts, src/import-export/contact-importer.test.ts — CSV/JSON round-trips, field mapping, default tags, dedup, error rows
  • src/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).