# 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` 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`).