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

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 ContactsApiError with code CONTACTS_API.INVALID_INPUT carrying the ZodError as cause

Scenario: descriptor exposes defaults

  • WHEN factory is called with no arguments
  • THEN descriptor defaults equals { 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\. across src/**/*.ts (excluding tests)
  • THEN every member of CONTACTS_API_CODES appears at least once

Scenario: error class preserves cause chain

  • WHEN repository raises with cause: new Error("boom")
  • THEN error.cause instanceof Error and error.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-backed MeterProvider, calls resetContactsApiInstruments, then triggers POST /contacts once
  • 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 }) then repo.getById(id)
  • THEN the returned Contact matches the created shape with createdAt / updatedAt as Date instances

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/members route twice with the same contact id
  • THEN the second call returns { added: 0 } (no MEMBERSHIP_EXISTS thrown — 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 /contacts without an auth session
  • THEN response is 401 with { success: false, error: "Unauthorized" }

Scenario: principal without tenantId is rejected

  • WHEN test posts to /contacts with a Principal lacking tenantId
  • THEN response is 401 with { success: false, error: "Unauthorized" }

Scenario: well-formed contact create returns 201 + body

  • WHEN authenticated request posts a valid CreateContactSchema body
  • 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.errors contains { index: 2, error: <message> } and data.imported equals 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 repo is typed as SurrealContactRepository | 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 onInit runs against a fresh Surreal namespace/database
  • THEN the three tables exist and a subsequent INFO FOR TABLE contact succeeds

Scenario: schema apply failure is wrapped

  • WHEN the apply step throws (simulated by closing the adapter before onInit)
  • THEN the onInit promise rejects with CONTACTS_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"