10 KiB
Capability Spec: api-plugin-contacts
Package: @suaveplan/api-plugin-contacts
Status: ACTIVE (folded 2026-05-20 from migrate-api-plugin-contacts)
ADDED Requirements
Requirement: Plugin factory with validated configuration
The package SHALL expose createContactsPlugin(options?: ContactsPluginOptions): PluginDescriptor<ContactsPluginConfig> that Zod-validates options at construction time and returns a typed PluginDescriptor declaring dependencies: ["database", "auth"] so the host plugin manager topo-sorts after @suaveplan/api-plugin-database and @suaveplan/api-plugin-auth. The descriptor SHALL surface the same identifiers as the upstream 1.0.0 API (name: "contacts", tags: ["contacts", "groups", "audience"]) but ship at 0.1.0.
Scenario: factory rejects invalid options
- WHEN caller passes
maxGroupDepth: -1 - THEN factory throws
ContactsApiErrorwith codeCONTACTS_API.INVALID_INPUTcarrying theZodErrorascause
Scenario: descriptor exposes defaults
- WHEN factory is called with no arguments
- THEN descriptor
defaultsequals{ routePrefix: "/", maxImportBatchSize: 1000, maxGroupDepth: 10, enforceGroupDepth: true }
Verified by: src/plugin/plugin.test.ts::"createContactsPlugin returns a typed descriptor with default config" AND src/plugin/plugin.test.ts::"createContactsPlugin throws CONTACTS_API.INVALID_INPUT on invalid options" AND src/api-plugin-contacts.spec-conformance.test.ts::"descriptor advertises name, dependencies, version, tags"
Requirement: Stable error-code registry
The package SHALL export CONTACTS_API_CODES (built with defineCodes("CONTACTS_API", […])) and a ContactsApiError extends SuaveplanError<ContactsApiErrorCode> class. Every failure path in the package SHALL raise a ContactsApiError carrying one of the 12 stable codes.
Scenario: every registered code is thrown from production code
- WHEN static analysis greps
throw new ContactsApiError\(CONTACTS_API_CODES\.acrosssrc/**/*.ts(excluding tests) - THEN every member of
CONTACTS_API_CODESappears at least once
Scenario: error class preserves cause chain
- WHEN repository raises with
cause: new Error("boom") - THEN
error.cause instanceof Erroranderror.code === "CONTACTS_API.<code>"
Verified by: src/error-codes/error-codes.test.ts::"every code is a member of the CONTACTS_API namespace" AND src/error-codes/error-codes.test.ts::"ContactsApiError preserves cause" AND src/api-plugin-contacts.spec-conformance.test.ts::"every error code is thrown by at least one production path"
Requirement: OpenTelemetry instrument bundle emitted from production paths
The package SHALL export getContactsApiInstruments(): ContactsApiInstruments and resetContactsApiInstruments() and SHALL emit contacts_api.contact_writes_total, contacts_api.contact_reads_total, contacts_api.group_writes_total, contacts_api.membership_writes_total, contacts_api.import_duration_ms (histogram) and contacts_api.merge_duration_ms (histogram) from the corresponding route handlers.
Scenario: in-memory MeterProvider receives counter increments
- WHEN integration test installs an
InMemoryMetricExporter-backedMeterProvider, callsresetContactsApiInstruments, then triggersPOST /contactsonce - THEN the collected metrics include
contacts_api.contact_writes_total{outcome="success"}=1
Verified by: src/otel/instruments.test.ts::"getContactsApiInstruments returns the six-instrument bundle" AND src/otel/instruments.test.ts::"resetContactsApiInstruments rebinds against a freshly installed MeterProvider" AND tests/integration/full-boot.test.ts::"emits contacts_api.contact_writes_total on POST /contacts"
Requirement: SurrealDB-backed store implementations
The package SHALL export SurrealContactRepository, SurrealGroupRepository, and SurrealMembershipRepository implementing the genesis @suaveplan/contacts ContactStore, GroupStore, and MembershipStore interfaces exactly (including generateId()). Repositories SHALL accept a connected SurrealAdapter via the constructor and SHALL NOT open or close it.
Scenario: ContactStore roundtrip
- WHEN integration test calls
repo.create({ tenantId, displayName, channels })thenrepo.getById(id) - THEN the returned
Contactmatches the created shape withcreatedAt/updatedAtasDateinstances
Scenario: GroupStore enforces maxGroupDepth
- WHEN factory was called with
maxGroupDepth: 2, enforceGroupDepth: true, the repository builds depth-0 → depth-1 → depth-2 groups, and then attempts to create a depth-3 child - THEN the create call throws
CONTACTS_API.GROUP_DEPTH_EXCEEDED
Scenario: MembershipStore add is idempotent through repository
- WHEN caller invokes the
POST /groups/:id/membersroute twice with the same contact id - THEN the second call returns
{ added: 0 }(noMEMBERSHIP_EXISTSthrown — uniqueness handled at the route boundary by an existence pre-check)
Verified by: src/repositories/contact-repository.test.ts::"create + getById roundtrip against fake adapter" AND src/repositories/group-repository.test.ts::"createGroup raises GROUP_DEPTH_EXCEEDED past maxGroupDepth" AND src/repositories/membership-repository.test.ts::"add raises MEMBERSHIP_EXISTS on duplicate" AND tests/integration/full-boot.test.ts::"contact roundtrip against real SurrealDB" AND tests/integration/group-depth.test.ts::"createGroup raises GROUP_DEPTH_EXCEEDED when enforceGroupDepth is on"
Requirement: Typed routes with Zod boundaries
The package SHALL mount /contacts and /groups route trees on the host OpenAPIHono via @hono/zod-openapi. Every route SHALL declare request and response Zod schemas and SHALL read the current Principal from c.get("user") (populated upstream by @suaveplan/api-plugin-auth's requireAuth() middleware). Routes SHALL NOT use as any casts.
Scenario: unauthenticated request is rejected
- WHEN test posts to
/contactswithout an auth session - THEN response is 401 with
{ success: false, error: "Unauthorized" }
Scenario: principal without tenantId is rejected
- WHEN test posts to
/contactswith aPrincipallackingtenantId - THEN response is 401 with
{ success: false, error: "Unauthorized" }
Scenario: well-formed contact create returns 201 + body
- WHEN authenticated request posts a valid
CreateContactSchemabody - THEN response is 201 with
{ success: true, data: { id, … } }
Verified by: src/routes/contacts/build-routes.test.ts::"POST /contacts rejects requests without a tenantId" AND src/routes/contacts/build-routes.test.ts::"POST /contacts returns 201 on a valid body" AND src/routes/groups/build-routes.test.ts::"GET /groups/:id/tree returns a typed GroupTreeNode" AND tests/integration/full-boot.test.ts::"full contact + group + membership roundtrip"
Requirement: Bulk-import endpoint honours maxImportBatchSize
The package SHALL expose POST /contacts/import whose body is { contacts: CreateContactSchema[], skipDuplicates: boolean } capped at maxImportBatchSize. The route SHALL Zod-reject batches larger than the cap with 400 and SHALL return { imported, skipped, errors: [{ index, error }] } with errors populated per failing row.
Scenario: oversized batch is rejected at the schema boundary
- WHEN caller posts a batch of length
maxImportBatchSize + 1 - THEN response is 400 (Zod boundary) and no rows are created
Scenario: per-row errors reported with original index
- WHEN caller posts a batch where row index 2 violates a database constraint
- THEN response body
data.errorscontains{ index: 2, error: <message> }anddata.importedequals the count of successful rows
Verified by: src/routes/contacts/build-routes.test.ts::"POST /contacts/import rejects oversized batches at the schema boundary" AND tests/integration/bulk-import.test.ts::"per-row errors are reported with the original index against real SurrealDB"
Requirement: Typed Hono ContextVariableMap and ServiceRegistry augmentation
The package SHALL augment Hono's ContextVariableMap with contactRepository, groupRepository, membershipRepository, and eventBus slots, and SHALL augment @suaveplan/api-plugin/service-registry's ServiceRegistry interface with contactRepository, groupRepository, membershipRepository slots so sibling plugins can resolve them via ctx.getService(…) without a cast.
Scenario: type-level access compiles without as
- WHEN consumer writes
const repo = c.get("contactRepository")inside a Hono handler in TypeScript - THEN
repois typed asSurrealContactRepository | undefined
Verified by: src/context/context.test.ts::"ContextVariableMap exposes contactRepository slot typed as SurrealContactRepository" AND src/api-plugin-contacts.spec-conformance.test.ts::"ServiceRegistry augments contactRepository, groupRepository, membershipRepository slots"
Requirement: Schema migration via bundled SurrealQL
The package SHALL ship surql/tables/{contact,contact_group,group_membership}.surql and SHALL apply them on onInit either via the host MigrationRunner (when present in ServiceRegistry) or via direct SurrealAdapter.execute calls otherwise. Errors at apply time SHALL throw CONTACTS_API.SCHEMA_APPLY_FAILED with the underlying cause attached.
Scenario: schema apply succeeds against fresh SurrealDB
- WHEN plugin
onInitruns against a fresh Surreal namespace/database - THEN the three tables exist and a subsequent
INFO FOR TABLE contactsucceeds
Scenario: schema apply failure is wrapped
- WHEN the apply step throws (simulated by closing the adapter before
onInit) - THEN the
onInitpromise rejects withCONTACTS_API.SCHEMA_APPLY_FAILED
Verified by: src/plugin/on-init.test.ts::"onInit raises SCHEMA_APPLY_FAILED when execute throws" AND tests/integration/full-boot.test.ts::"onInit applies surql tables against real SurrealDB"