Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
# Migrate @suaveplan/api-plugin-contacts to genesis at enterprise quality
**Package**: `@suaveplan/api-plugin-contacts`
**Status**: PROPOSED
**Type**: new-package
## Why
`@suaveplan/api-plugin-contacts` is the HTTP face of `@suaveplan/contacts`. The upstream version on `main` shipped to 1.0.0 with ~30 `as any` casts in route handlers, untyped `buildTree`, no shared error hierarchy, no OTel instrumentation, no real-database integration tests, and `maxGroupDepth` declared in config but never enforced. Genesis policy forbids all of those defects, so the package is being rebuilt at 0.1.0 against the genesis `@suaveplan/contacts` store interfaces (`ContactStore` / `GroupStore` / `MembershipStore` with `generateId`) rather than the upstream port. Migrating closes the `contacts` plugin family — the first two siblings (`@suaveplan/contacts` and `@suaveplan/macos-contacts`) are already at gold standard.
## What changes
- ADDED: `createContactsPlugin(options?: ContactsPluginOptions): PluginDescriptor<ContactsPluginConfig>` — factory mounting `/contacts` and `/groups` route trees on the host `OpenAPIHono`.
- ADDED: `ContactsPluginOptionsSchema`, `ContactsPluginOptions`, `ContactsPluginConfig` — Zod-validated configuration.
- ADDED: `SurrealContactRepository`, `SurrealGroupRepository`, `SurrealMembershipRepository` — `@suaveplan/contacts` store-interface implementations over `SurrealAdapter`.
- ADDED: `CONTACTS_API_CODES` / `ContactsApiError` (`@suaveplan/error` based) — 12 stable codes covering every failure path.
- ADDED: `getContactsApiInstruments` / `resetContactsApiInstruments` — 6 OTel instruments (4 counters + 2 histograms) emitted from production hot paths.
- ADDED: Typed Hono `ContextVariableMap` augmentation — `contactRepository`, `groupRepository`, `membershipRepository`, `eventBus` slots (eliminates upstream's `as any` casts).
- ADDED: `ServiceRegistry` augmentation — exposes `contactRepository` / `groupRepository` / `membershipRepository` as typed services to sibling plugins.
- ADDED: SurrealQL schema files for `contact`, `contact_group`, `group_membership` tables and the `MigrationRunner` integration.
- ADDED: Bulk-import endpoint honouring `maxImportBatchSize` with per-row error reporting (Zod-validated).
- ADDED: Repository-level `maxGroupDepth` enforcement (`createGroup` and `moveGroup` raise `CONTACTS_API.GROUP_DEPTH_EXCEEDED`).
- ADDED: Typed `buildTree(root: Group, descendants: readonly Group[]): GroupTreeNode` helper.
- ADDED: Integration tests using `@suaveplan/testing-integration-surrealdb` testcontainers — real Surreal v3, no mocks.
## Out of scope
- CSV / VCF import parsing — already lives in `@suaveplan/contacts/import-export/contact-importer`; the bulk-import endpoint accepts pre-parsed JSON rows.
- GDPR export / erase, schema-registry versioning, audit-log integration, rate-limiting, dedup, domain normalisation — `enterprise/api-plugin-contacts.md` §3-4 list these as *plugin-composition* improvements. They belong on the host (already mountable via `@suaveplan/api-plugin-audit`, `@suaveplan/api-plugin-rate-limit`, `@suaveplan/api-plugin-export`); duplicating them inside this plugin would re-publish behaviour the host owns.
- Sync adapters (Google / iCloud / Outlook) and a saved-segments rules engine — `enterprise/api-plugin-contacts.md` §4 items 8/3, deferred to dedicated plugins.
## Source mapping
This change derives from `../suaveplan/enterprise/api-plugin-contacts.md` and the upstream source at `../suaveplan/packages/server/api-plugin-contacts/src/`.
| Requirement | Source lines | Notes |
|---|---|---|
| Re-export `ContactsConfigSchema` + `createContactsPlugin` from root | L57, L98, L113 | verbatim; genesis names them `ContactsPluginConfigSchema` + `createContactsPlugin` |
| `maxImportBatchSize` (default 1000) and `maxGroupDepth` (default 10) config | L12, L49 | verbatim |
| `/contacts` + `/groups` route trees with `/groups/:id/members` and `/groups/:id/resolve` | L12, L37-L38 | upstream files `routes/contacts/*`, `routes/groups/*` |
| `SurrealContactRepository`, `SurrealGroupRepository`, `SurrealMembershipRepository` exported | L21 | verbatim |
| Hierarchical group nesting with materialised `path` + `depth` | L12, L49 | upstream `group.repository.ts` L19-L58 |
| Enforce `maxGroupDepth` in repository on insert + move | L49, L99 | upstream gap closed; raises `GROUP_DEPTH_EXCEEDED` |
| Bulk-import endpoint honouring `maxImportBatchSize` with per-row errors | L50, L99-L100 | upstream `routes/contacts/contacts.routes.ts` L218-L263 baseline; genesis adds Zod boundary + typed errors |
| Merge contacts endpoint (channel + tag union) | upstream `contacts.routes.ts` L265-L331 | preserved |
| Group subtree (`GET /groups/:id/tree`) + reparent (`POST /groups/:id/move`) | upstream `groups.routes.ts` L206-L270 | preserved; `buildTree` becomes fully typed |
| Group membership add / remove / list / resolve | upstream `groups.members.routes.ts` L24-L196 | preserved |
| Zod schemas on every HTTP body | L79 | verbatim; `@hono/zod-openapi` adapter |
| Real-SurrealDB integration tests via `test:with-db:filter` | L107, L118 | genesis uses `@suaveplan/testing-integration-surrealdb` testcontainers |
| `@suaveplan/error` hierarchy with typed code registry | L81, L105 | upstream gap closed (12 codes) |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| OpenTelemetry instruments emitted from production paths | Genesis-wide observability policy (`CLAUDE.md` Code Organization) — every plugin emits counters + histograms; not package-specific in upstream `enterprise/*.md`. |
| Typed Hono `ContextVariableMap` augmentation in place of `as any` casts | Closes a defect in the upstream port (~30 `as any` casts in `routes/**`) flagged by genesis CLAUDE.md rule 9 (no static-analysis suppression / cast-only escape hatches). |
| 100% Istanbul coverage threshold | Genesis-wide policy. |
| `@suaveplan/testing/runner` for all test primitives | CLAUDE.md rule 11. |
## Verification approach
- `src/error-codes/error-codes.test.ts` — covers code registry stability; pinned by `tests/dep-graph.test.ts` for SuaveplanError lineage.
- `src/otel/instruments.test.ts` — asserts every instrument binds against a real in-memory `MeterProvider` after `resetContactsApiInstruments`.
- `src/repositories/contact-repository.test.ts`, `src/repositories/group-repository.test.ts`, `src/repositories/membership-repository.test.ts` — unit-shape tests for query builder + mapping with an injectable adapter contract.
- `src/routes/contacts/build-routes.test.ts`, `src/routes/groups/build-routes.test.ts` — route-tree composition and shape assertions.
- `src/plugin/plugin.test.ts` — plugin descriptor validation, ServiceRegistry slot registration, middleware injection.
- `src/api-plugin-contacts.spec-conformance.test.ts` — pins every public-API claim: exported symbols, error-code registry, OTel instrument names, route paths, ServiceRegistry slots.
- `tests/integration/full-boot.test.ts` — boots auth + database + contacts plugins against real SurrealDB testcontainers and walks the full CRUD + membership + audience-resolve roundtrip.
- `tests/integration/group-depth.test.ts` — pins the `maxGroupDepth` repository guard against real SurrealDB.
- `tests/integration/bulk-import.test.ts` — pins per-row error reporting against real SurrealDB.
## Migration impact
None — additive only. No consumer of `@suaveplan/api-plugin-contacts` exists in genesis yet.

View File

@@ -0,0 +1,156 @@
# Capability Spec Delta: api-plugin-contacts (migrate-api-plugin-contacts)
**Package**: `@suaveplan/api-plugin-contacts`
**Status**: PROPOSED
## 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 422 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/types/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"`
## MODIFIED Requirements
_(none — new package)_
## REMOVED Requirements
_(none — new package)_

View File

@@ -0,0 +1,65 @@
# Tasks — migrate-api-plugin-contacts
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/api-plugin-contacts.md` end-to-end
- [x] 1.2 Read `openspec/specs/api-plugin-contacts/spec.md` (does not exist yet — new package)
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
- [x] 1.5 Fill in `## Source mapping` table
- [x] 1.6 Draft `specs/api-plugin-contacts/spec.md` from `openspec/templates/spec-delta.md`
- [x] 1.7 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
## 2. Implementation
- [x] 2.1 Scaffold package (`package.json`, `tsconfig*`, `vite.config.ts`, `vitest.config.ts`, README)
- [x] 2.2 Port SurrealQL schema to `surql/tables/{contact,contact_group,group_membership}.surql`
- [x] 2.3 Define `CONTACTS_API_CODES` + `ContactsApiError` (12 codes)
- [x] 2.4 Define OTel instruments (4 counters + 2 histograms)
- [x] 2.5 Implement typed Hono `ContextVariableMap` + `ServiceRegistry` augmentations
- [x] 2.6 Implement `SurrealContactRepository` against `ContactStore`
- [x] 2.7 Implement `SurrealGroupRepository` against `GroupStore` with `maxGroupDepth` enforcement
- [x] 2.8 Implement `SurrealMembershipRepository` against `MembershipStore`
- [x] 2.9 Implement typed `buildTree` helper
- [x] 2.10 Implement contact route builders (list, create, get, update, delete, bulk-import, merge)
- [x] 2.11 Implement group route builders (list, create, get, update, delete, tree, move)
- [x] 2.12 Implement group-member route builders (list, add, remove, resolve)
- [x] 2.13 Implement `createContactsPlugin` factory wiring config validation, ServiceRegistry slots, middleware, schema migration
- [x] 2.14 Root barrel re-exports all public symbols including `ContactsPluginConfigSchema`
## 3. Tests
- [x] 3.1 Unit tests at `src/**/*.test.ts` covering every public symbol
- [x] 3.2 Spec-conformance tests at `src/api-plugin-contacts.spec-conformance.test.ts`
- [x] 3.3 Real-SurrealDB integration tests under `tests/integration/`
- [x] 3.4 Every `it(...)` carries `{ timeout: 5000 }` (or up to 180_000 for testcontainer beforeAll)
## 4. Documentation
- [x] 4.1 Sibling `.md` for every implementation `.ts`
- [x] 4.2 README links every co-located `.md` under `## Modules`
- [x] 4.3 Update `CLAUDE.md` package listing — add `### Contacts` section
## 5. Pre-archive verification (the 5-gate triple)
- [x] 5.1 `bun run check` — Biome 0/0/0
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-contacts` — green
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-contacts` — 100/100/100/100
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-contacts` — exit 0
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-plugin-contacts` — green
## 6. Pre-archive 7-row checklist
- [x] 6.1 Generics threaded; `expectTypeOf` assertions on public types
- [x] 6.2 Zod schemas wired at every declared boundary
- [x] 6.3 Every error code thrown by ≥1 production path
- [x] 6.4 Strict-mode behaviors tested both on and off (depth enforcement on/off)
- [x] 6.5 OTel instruments emitted from production paths and asserted with real `MeterProvider`
- [x] 6.6 Lifecycle dedup tested where claimed (idempotent membership add)
- [x] 6.7 No retroactive tick
## 7. Archive
- [x] 7.1 Fold spec delta into `openspec/specs/api-plugin-contacts/spec.md`
- [x] 7.2 Move change folder to `openspec/archive/2026-05-20-migrate-api-plugin-contacts/`
- [x] 7.3 Refresh `MANIFEST.md`