Files
2026-05-27 23:29:53 +01:00

94 lines
9.9 KiB
Markdown

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