Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
# @suaveplan/api-plugin-contacts - Contacts + groups HTTP plugin for @suaveplan/api. Mounts /contacts and /groups (with /groups/:id/members and /groups/:id/resolve) on a Hono OpenAPI registry, backed by SurrealDB-implementations of the @suaveplan/contacts ContactStore / GroupStore / MembershipStore interfaces. Bulk-import with per-row error reporting, repository-level maxGroupDepth enforcement, six OTel instruments, typed Hono ContextVariableMap + ServiceRegistry augmentations, and the 12-code CONTACTS_API.* error namespace.
Contacts and Groups HTTP plugin for [`@suaveplan/api`](../../api/api).
Mounts the `/contacts` and `/groups` route trees on a Hono
`OpenAPIHono`, backed by SurrealDB implementations of the
[`@suaveplan/contacts`](../contacts) store interfaces.
<!-- begin:auto-docs -->
## Documentation tree
- [context](./src/context/context.md) — Module augmentation declaring four typed slots on Hono's `ContextVariableMap`: `contactRepository`, `groupRepository`,…
- [context](./src/context/context.md) — Module augmentation declaring four typed slots on Hono's `ContextVariableMap`: `contactRepository`, `groupRepository`,…
- [error-codes](./src/error-codes/error-codes.md) — Single source of truth for every failure path inside `@suaveplan/api-plugin-contacts`. Built on the `@suaveplan…
- [error-codes](./src/error-codes/error-codes.md) — Single source of truth for every failure path inside `@suaveplan/api-plugin-contacts`. Built on the `@suaveplan…
- [otel](./src/otel/instruments.md) — Exposes the six OpenTelemetry instruments emitted by `@suaveplan/api-plugin-contacts` in production: four counters cove…
- [instruments](./src/otel/instruments.md) — Exposes the six OpenTelemetry instruments emitted by `@suaveplan/api-plugin-contacts` in production: four counters cove…
- [plugin](./src/plugin/plugin.md) — Returns the `PluginDescriptor<ContactsPluginConfig>` consumed by the `@suaveplan/api` plugin host. Runs the 5-step `onI…
- [plugin](./src/plugin/plugin.md) — Returns the `PluginDescriptor<ContactsPluginConfig>` consumed by the `@suaveplan/api` plugin host. Runs the 5-step `onI…
- [schema](./src/plugin/schema.md) — Inlined SurrealQL DDL for the three tables the contacts plugin owns: `contact`, `contact_group`, and `group_membership`…
- [contact-repository](./src/repositories/contact-repository.md) — Concrete implementation of the universal `ContactStore` interface from `@suaveplan/contacts`, backed by a connecte…
- [group-repository](./src/repositories/group-repository.md) — Concrete implementation of the `GroupStore` interface from `@suaveplan/contacts`, backed by `SurrealAdapter`. I…
- [membership-repository](./src/repositories/membership-repository.md) — Concrete implementation of the `MembershipStore` interface from `@suaveplan/contacts`, backed by `SurrealAdapte…
- [build-routes](./src/routes/contacts/build-routes.md) — Returns the seven `PluginRouteEntry` values that compose the `/contacts` route tree. Mounted by the plugin's `onInit` v…
- [contacts-bulk-routes](./src/routes/contacts/contacts-bulk-routes.md) — Two `PluginRouteEntry` builders for the bulk-operation contact endpoints: `POST /contacts/import` and `POST /contacts/m…
- [contacts-crud-routes](./src/routes/contacts/contacts-crud-routes.md) — Five `PluginRouteEntry` builders for the contact CRUD endpoints: `GET /contacts`, `POST /contacts`, `GET /contacts/:id`…
- [build-routes](./src/routes/groups/build-routes.md) — Returns the 11 `PluginRouteEntry` values that compose the `/groups` route tree: 5 CRUD (`list`, `create`, `get`, `updat…
- [groups-crud-routes](./src/routes/groups/groups-crud-routes.md) — Five `PluginRouteEntry` builders for the group CRUD endpoints: `GET /groups`, `POST /groups`, `GET /groups/:id`, `PATCH…
- [groups-members-routes](./src/routes/groups/groups-members-routes.md) — Four `PluginRouteEntry` builders for the group-membership endpoints: `GET /groups/:id/members`, `POST /groups/:id/membe…
- [groups-tree-routes](./src/routes/groups/groups-tree-routes.md) — Two `PluginRouteEntry` builders for the hierarchical-group endpoints: `GET /groups/:id/tree` materialises a typed `Grou…
- [require-tenant](./src/routes/shared/require-tenant.md) — Centralised guard that extracts and validates the request `Principal` on every `/contacts` and `/groups` handler. Retur…
- [strip-undefined](./src/routes/shared/strip-undefined.md) — Bridges Zod's optional-field semantics (`.optional() → string | undefined`) with the `@suaveplan/contacts` inpu…
- [contacts-schemas](./src/schemas/contacts-schemas.md) — Zod schemas mounted on every `/contacts` route via `@hono/zod-openapi`. They validate inbound bodies / query strings, s…
- [groups-schemas](./src/schemas/groups-schemas.md) — Zod schemas for the seven `/groups` routes (list, create, get, update, delete, tree, move) plus the three `/groups/:id/…
- [options](./src/schemas/options.md) — Zod schema that validates the optional bag of options passed to `createContactsPlugin`. Every field is optional with a…
- [services](./src/services/registry-augmentation.md) — Adds typed `contactRepository`, `groupRepository`, and `membershipRepository` slots to the shared `@suaveplan/api-plugi…
- [registry-augmentation](./src/services/registry-augmentation.md) — Adds typed `contactRepository`, `groupRepository`, and `membershipRepository` slots to the shared `@suaveplan/api-plugi…
- [tree](./src/tree/build-tree.md) — Constructs a typed `GroupTreeNode` rooted at the supplied `Group`, given a flat list of descendants in arbitrary order.…
- [build-tree](./src/tree/build-tree.md) — Constructs a typed `GroupTreeNode` rooted at the supplied `Group`, given a flat list of descendants in arbitrary order.…
- [types](./src/types/types.md) — Pure type declarations that downstream consumers of `@suaveplan/api-plugin-contacts` depend on at compile time. Keeping…
- [types](./src/types/types.md) — Pure type declarations that downstream consumers of `@suaveplan/api-plugin-contacts` depend on at compile time. Keeping…
<!-- end:auto-docs -->
## Status
- Version: `0.1.0`
- Tier: server / universal
- suaveplan-policy: 100% test coverage, 0 mocks, real SurrealDB via
`@suaveplan/testing-integration-surrealdb`.
## Quick start
```ts
import { createContactsPlugin } from "@suaveplan/api-plugin-contacts";
const plugin = createContactsPlugin({
routePrefix: "/api",
maxImportBatchSize: 500,
maxGroupDepth: 10,
enforceGroupDepth: true,
});
// host.use(plugin);
```
The plugin is suaveplan-compatible with the `@suaveplan/api-plugin`
descriptor contract; load it after the database and auth plugins so
the ServiceRegistry `db` and `authService` slots resolve.
## Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | `/contacts` | List contacts (search / status / channelType / tags / paging) |
| POST | `/contacts` | Create a contact |
| GET | `/contacts/:id` | Read a contact (tenant-scoped) |
| PATCH | `/contacts/:id` | Update a contact |
| DELETE | `/contacts/:id` | Delete a contact |
| POST | `/contacts/import` | Bulk import (Zod-capped) with per-row errors |
| POST | `/contacts/merge` | Channel + tag union into a primary contact |
| GET | `/groups` | List groups |
| POST | `/groups` | Create a group (depth-enforced) |
| GET | `/groups/:id` | Read a group |
| PATCH | `/groups/:id` | Update a group |
| DELETE | `/groups/:id` | Delete a group (cascade members) |
| GET | `/groups/:id/tree` | Typed `GroupTreeNode` subtree |
| POST | `/groups/:id/move` | Reparent (depth-enforced) |
| GET | `/groups/:id/members` | List members |
| POST | `/groups/:id/members` | Idempotent add |
| DELETE | `/groups/:id/members` | Idempotent remove |
| GET | `/groups/:id/resolve` | Flatten group + descendants → unique contacts |
## Modules
- [`error-codes/error-codes`](./src/error-codes/error-codes.md) — 12 `CONTACTS_API.*` codes + `ContactsApiError`.
- [`otel/instruments`](./src/otel/instruments.md) — six OTel instruments.
- [`schemas/options`](./src/schemas/options.md) — plugin options Zod schema.
- [`schemas/contacts-schemas`](./src/schemas/contacts-schemas.md) — `/contacts` request/response shapes.
- [`schemas/groups-schemas`](./src/schemas/groups-schemas.md) — `/groups` request/response shapes.
- [`repositories/contact-repository`](./src/repositories/contact-repository.md) — `SurrealContactRepository`.
- [`repositories/group-repository`](./src/repositories/group-repository.md) — `SurrealGroupRepository`.
- [`repositories/membership-repository`](./src/repositories/membership-repository.md) — `SurrealMembershipRepository`.
- [`routes/shared/require-tenant`](./src/routes/shared/require-tenant.md) — Principal → tenantId guard.
- [`routes/contacts/build-routes`](./src/routes/contacts/build-routes.md) — `/contacts` aggregator.
- [`routes/contacts/contacts-crud-routes`](./src/routes/contacts/contacts-crud-routes.md) — CRUD builders.
- [`routes/contacts/contacts-bulk-routes`](./src/routes/contacts/contacts-bulk-routes.md) — import + merge builders.
- [`routes/groups/build-routes`](./src/routes/groups/build-routes.md) — `/groups` aggregator.
- [`routes/groups/groups-crud-routes`](./src/routes/groups/groups-crud-routes.md) — CRUD builders.
- [`routes/groups/groups-tree-routes`](./src/routes/groups/groups-tree-routes.md) — tree + move.
- [`routes/groups/groups-members-routes`](./src/routes/groups/groups-members-routes.md) — membership + resolve.
- [`tree/build-tree`](./src/tree/build-tree.md) — typed subtree builder.
- [`plugin/schema`](./src/plugin/schema.md) — bundled SurrealQL DDL + apply helper.
- [`plugin/plugin`](./src/plugin/plugin.md) — `createContactsPlugin` factory.
- [`types/types`](./src/types/types.md) — public configuration types.
- [`context/context`](./src/context/context.md) — Hono `ContextVariableMap` augmentation.
- [`services/registry-augmentation`](./src/services/registry-augmentation.md) — `ServiceRegistry` augmentation.
## Tests
```sh
bunx turbo test --filter=@suaveplan/api-plugin-contacts
bunx turbo test:coverage --filter=@suaveplan/api-plugin-contacts
```
Integration tests under `tests/integration/` boot a SurrealDB
testcontainer (Docker required) and exercise the full plugin
lifecycle end-to-end.

View File

@@ -0,0 +1,75 @@
# context — Hono ContextVariableMap augmentation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Module augmentation declaring four typed slots on Hono's
`ContextVariableMap`: `contactRepository`, `groupRepository`,
`membershipRepository`, and `eventBus`. Importing this module for its
side effects (the package root barrel does it automatically) means
every route handler in this plugin's `/contacts` and `/groups` trees
reads `c.get("contactRepository")` as `SurrealContactRepository | undefined`
without an `as` cast.
The upstream port shipped ~30 `as any` casts in its route handlers to
work around the missing augmentation. suaveplan CLAUDE.md rule 9 forbids
static-analysis suppression; this module is the structural fix.
## Features
- Declares four typed slots on Hono's request context.
- Zero runtime emit — the `declare module` block is removed by the TS
transformer.
- Imported by `src/index.ts` for side effects so consumers of the
package see the augmentation automatically.
## Usage: Basic
```ts
import "hono"; // brings in our augmentation transitively via the plugin root
import type { Context } from "hono";
function handler(c: Context) {
const repo = c.get("contactRepository");
// repo is typed as SurrealContactRepository | undefined.
return c.json({ ok: repo !== undefined });
}
```
## Usage: Advanced
```ts
import type { Context } from "hono";
import type { SurrealContactRepository } from "@suaveplan/api-plugin-contacts/repositories/contact-repository";
function listContactsHandler(c: Context) {
const repo = c.get("contactRepository");
if (repo === undefined) {
throw new Error("contactRepository missing — plugin not mounted");
}
return repo.list({ tenantId: c.get("user")!.tenantId! });
}
```
## API Reference
- Module augmentation only — no runtime exports. The slots are:
- `contactRepository: SurrealContactRepository`
- `groupRepository: SurrealGroupRepository`
- `membershipRepository: SurrealMembershipRepository`
- `eventBus: EventBusLike`
## Implementation Notes
- Slots are typed as `T` (not `T | undefined`); `c.get` returns
`T | undefined` at the Hono type level, so accidental reads before
the plugin middleware runs are caught by the compiler.
- Augmentations are global once imported — every Hono app in the
process sees them. This is the standard TypeScript module-merging
trade-off; sibling plugins in `packages/api/api-plugin-*` rely on the
same pattern.
- The export `{}` line is required so TypeScript treats the file as a
module rather than a global script.

View File

@@ -0,0 +1,107 @@
# error-codes — stable CONTACTS_API.* registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Single source of truth for every failure path inside
`@suaveplan/api-plugin-contacts`. Built on the suaveplan
`@suaveplan/error` foundation (`defineCodes` + `SuaveplanError`) so
codes are typed as a string-literal union and the registry is wire-
stable across versions. Consumers (UI translation layers, response
encoders, log shippers) branch on `error.code` without sniffing the
message text.
The 12 codes split cleanly into three groups: lifecycle errors
(`INVALID_INPUT`, `SERVICE_NOT_AVAILABLE`, `SCHEMA_APPLY_FAILED`,
`ROUTE_PREFIX_COLLISION`), boundary errors visible to HTTP callers
(`NOT_FOUND`, `UNAUTHORIZED`, `IMPORT_TOO_LARGE`), and operation
errors raised by the SurrealDB repositories (`CREATE_FAILED`,
`UPDATE_FAILED`, `DELETE_FAILED`, `GROUP_DEPTH_EXCEEDED`,
`MEMBERSHIP_EXISTS`).
## Features
- Stable code strings prefixed with `CONTACTS_API.` (built via
`defineCodes`) — safe to log or send to clients.
- `ContactsApiError` subclass of `SuaveplanError` preserves the
underlying `cause`, optional structured `context`, and a redactable
message.
- Codes are exhaustive: every `throw` in the production tree uses
exactly one of the 12; the spec-conformance test pins this.
## Usage: Basic
```ts
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "@suaveplan/api-plugin-contacts";
throw new ContactsApiError(
CONTACTS_API_CODES.NOT_FOUND,
"contact not found",
{ context: { statusCode: 404 } }
);
```
## Usage: Advanced
```ts
import {
CONTACTS_API_CODES,
type ContactsApiErrorCode,
ContactsApiError,
} from "@suaveplan/api-plugin-contacts";
function toHttpStatus(code: ContactsApiErrorCode): number {
switch (code) {
case CONTACTS_API_CODES.NOT_FOUND:
return 404;
case CONTACTS_API_CODES.UNAUTHORIZED:
return 401;
case CONTACTS_API_CODES.IMPORT_TOO_LARGE:
case CONTACTS_API_CODES.GROUP_DEPTH_EXCEEDED:
case CONTACTS_API_CODES.INVALID_INPUT:
return 400;
case CONTACTS_API_CODES.MEMBERSHIP_EXISTS:
return 409;
default:
return 500;
}
}
try {
/* … */
} catch (err) {
if (err instanceof ContactsApiError) {
return Response.json(
{ success: false, code: err.code, error: err.message },
{ status: toHttpStatus(err.contactsApiCode) }
);
}
throw err;
}
```
## API Reference
- `CONTACTS_API_CODES` — frozen registry returned by
`defineCodes("CONTACTS_API", [...])`.
- `ContactsApiErrorCode` — string-literal union of every member of
`CONTACTS_API_CODES`.
- `class ContactsApiError extends SuaveplanError<ContactsApiErrorCode>`
— constructor `(code, message, options?)`. Stores the original code
on `contactsApiCode` for ergonomic narrowing.
## Implementation Notes
- The registry is declared `as const`; `defineCodes` returns the frozen
mapping (`{ CODE: "CONTACTS_API.CODE", … }`) so callers cannot
accidentally mutate it.
- The constructor delegates to `SuaveplanError`, which threads
`cause`, `context`, and a default `name` through. No code path here
re-implements that — adding a new code only requires adding a string
literal to the `as const` array.

View File

@@ -0,0 +1,92 @@
# otel/instruments — OpenTelemetry instrument bundle
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes the six OpenTelemetry instruments emitted by
`@suaveplan/api-plugin-contacts` in production: four counters covering
contact reads, contact writes, group writes, and membership writes, and
two histograms covering the wall-time of the bulk-import and merge
endpoints. Instruments are lazily bound against the global
`MeterProvider` at first use and cached for the lifetime of the
process; tests call `resetContactsApiInstruments()` to force a re-bind
after installing an in-memory `MeterProvider`.
The bundle is the only telemetry surface the plugin emits; route
handlers in `../routes/**` call `getContactsApiInstruments()` once at
import time and update the instruments inline. No spans are created
here — span propagation comes from the host Hono OpenTelemetry
middleware.
## Features
- Lazy binding — no `MeterProvider` work until the first call.
- Singleton cache, with `resetContactsApiInstruments()` escape hatch
for tests.
- All instrument names share the `contacts_api.` prefix to match the
`@suaveplan/api-plugin-contacts` meter scope.
- Counter labels follow the `{ outcome: "success" | "error" }`
convention used by sibling plugins (`api-plugin-user`).
## Usage: Basic
```ts
import { getContactsApiInstruments } from "@suaveplan/api-plugin-contacts";
const inst = getContactsApiInstruments();
inst.contactWritesTotal.add(1, { outcome: "success" });
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import {
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
AggregationTemporality,
} from "@opentelemetry/sdk-metrics";
import {
getContactsApiInstruments,
resetContactsApiInstruments,
} from "@suaveplan/api-plugin-contacts";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetContactsApiInstruments();
const start = performance.now();
getContactsApiInstruments().importDurationMs.record(
performance.now() - start,
{ outcome: "success" }
);
```
## API Reference
- `ContactsApiInstruments` — `readonly` bundle with the six
instruments described above.
- `getContactsApiInstruments(): ContactsApiInstruments` — returns the
cached bundle, building it lazily.
- `resetContactsApiInstruments(): void` — drops the cache. The next
call rebinds against the current global `MeterProvider`.
## Implementation Notes
- Histograms are declared with `unit: "ms"` so OTLP exporters
emit a `unit` attribute downstream.
- The `contactReadsTotal` counter has no `outcome` label because reads
that fail are converted into 4xx/5xx by the route handler and never
reach the counter increment.
- The cache is process-global; in serverless deployments where one
function instance handles a single request the lazy binding cost is
paid exactly once.

View File

@@ -0,0 +1,79 @@
# plugin/plugin — createContactsPlugin factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Returns the `PluginDescriptor<ContactsPluginConfig>` consumed by the
`@suaveplan/api` plugin host. Runs the 5-step `onInit` lifecycle:
1. Validate the supplied options through Zod (`INVALID_INPUT` on fail).
2. Resolve the host services (`app`, `db`, optional
`openAPIRegistry`, optional `migrationRunner`).
3. Apply the bundled SurrealQL schema unless
`applySchemaOnInit: false`.
4. Construct the three SurrealDB repositories and register them in
the `ServiceRegistry`.
5. Install a single mount-pattern middleware that injects repositories
+ `eventBus` into every request context, then register the 18
`/contacts` + `/groups` routes via `definePluginRoutes`.
The descriptor declares `dependencies: ["database", "auth"]` so the
host topo-sorts after `@suaveplan/api-plugin-database` and
`@suaveplan/api-plugin-auth`.
## Features
+ Single factory entry-point; idempotent within a process (re-calling
with the same options is safe — every `onInit` build is a fresh
bundle).
+ ServiceRegistry slot registration: `contactRepository`,
`groupRepository`, `membershipRepository`.
+ Hono ContextVariableMap injection: same three slots + `eventBus`.
+ Route-prefix collision detection raises
`ROUTE_PREFIX_COLLISION`.
+ Bare-mount fallback when `openAPIRegistry` is absent (routes
still work; the OpenAPI document just excludes them).
## Usage: Basic
```ts
import { createContactsPlugin } from "@suaveplan/api-plugin-contacts";
const plugin = createContactsPlugin();
// pluginManager.use(plugin);
```
## Usage: Advanced
```ts
import { createContactsPlugin } from "@suaveplan/api-plugin-contacts";
const plugin = createContactsPlugin({
routePrefix: "/api/v1",
maxImportBatchSize: 500,
maxGroupDepth: 5,
enforceGroupDepth: true,
applySchemaOnInit: false, // host has its own migration pipeline
});
```
## API Reference
+ `createContactsPlugin(options?: ContactsPluginOptions): PluginDescriptor<ContactsPluginConfig>`
+ `runOnInit(ctx, config): Promise<void>` — the lifecycle body; exported
for testability so tests can inject a hand-built `PluginContext`.
## Implementation Notes
+ The mount-pattern is computed as `*` when `routePrefix === "/"` and
`${prefix}/*` otherwise; this matches the convention used by the
user plugin and avoids the off-by-one Hono ignores trailing `/`.
+ ROUTE_PREFIX_COLLISION uses `${prefix}/contacts` as the probe so a
second mount of this plugin (even with a different prefix on the
same registry) trips the check.
+ The descriptor's `defaults` slot is the post-Zod-parse
`ContactsPluginConfig`, frozen by the schema's
`passthrough: false` semantics.

View File

@@ -0,0 +1,72 @@
# plugin/schema — bundled SurrealQL DDL
## Purpose
Inlined SurrealQL DDL for the three tables the contacts plugin owns:
`contact`, `contact_group`, and `group_membership`. The constants in
this module are bundled into the package's `dist/` so consumers
never hit the filesystem at runtime; the `surql/tables/*.surql` files
in the package root are the canonical editing source and are mirrored
verbatim here. The integration test executes
`CONTACTS_PLUGIN_SCHEMA` against a real SurrealDB testcontainer to
catch drift.
`applyContactsSchema` exposes the apply entry-point used by the
plugin's `onInit`. It prefers the host `MigrationRunner` (recording the
apply in the host's migrations table) and falls back to direct
`db.execute(...)` when no runner is present.
## Features
- Per-table constants (`CONTACT_TABLE_DDL`,
`CONTACT_GROUP_TABLE_DDL`, `GROUP_MEMBERSHIP_TABLE_DDL`) plus the
combined `CONTACTS_PLUGIN_SCHEMA`.
- Idempotent `DEFINE TABLE OVERWRITE … SCHEMAFULL` statements — safe
to re-apply.
- `applyContactsSchema({ migrationRunner?, db? })` raises typed
failures: `SCHEMA_APPLY_FAILED` on execute failure,
`SERVICE_NOT_AVAILABLE` when neither transport supplied.
## Usage: Basic
```ts
import { applyContactsSchema } from "@suaveplan/api-plugin-contacts";
await applyContactsSchema({ db });
```
## Usage: Advanced
```ts
import {
applyContactsSchema,
CONTACTS_PLUGIN_SCHEMA,
} from "@suaveplan/api-plugin-contacts";
// Recording the apply via the host migration runner:
await applyContactsSchema({ migrationRunner });
// Or pre-rendering the DDL for inspection / golden tests:
console.log(CONTACTS_PLUGIN_SCHEMA);
```
## API Reference
- `CONTACT_TABLE_DDL: string`, `CONTACT_GROUP_TABLE_DDL: string`,
`GROUP_MEMBERSHIP_TABLE_DDL: string` — per-table inline DDL.
- `CONTACTS_PLUGIN_SCHEMA: string` — the three table DDLs joined by
blank lines.
- `ApplyContactsSchemaOptions` — `{ migrationRunner?, db? }`.
- `applyContactsSchema(options): Promise<void>` — apply entry-point.
## Implementation Notes
- The DDL does not declare SurrealQL `PERMISSIONS` blocks; tenant
scoping is enforced at the handler layer via `Principal.tenantId`.
Operators wanting database-level enforcement can apply their own
permission overlay after the table DDL.
- `DEFINE INDEX` statements use `OVERWRITE` so re-applying the schema
does not raise "index already exists" errors.
- The migration record name `0001-contacts-plugin-schema` is stable
across versions so the host migration table tracks the same logical
apply across re-deploys.

View File

@@ -0,0 +1,90 @@
# repositories/contact-repository — SurrealDB-backed ContactStore
## Purpose
Concrete implementation of the universal `ContactStore` interface
from `@suaveplan/contacts`, backed by a connected
`SurrealAdapter`. Wraps every adapter call in `try/catch` blocks that
re-throw failures as `ContactsApiError` with the appropriate
`CREATE_FAILED` / `UPDATE_FAILED` / `DELETE_FAILED` code so callers can
discriminate without sniffing the underlying SurrealDB driver's
exception text.
The repository is **stateless aside from the adapter handle** — the
adapter's lifecycle (connect, disconnect, namespace selection) is the
host's responsibility. Multiple repositories may share one adapter.
## Features
- Full `ContactStore` surface: `create`, `getById`, `update`, `delete`,
`list` (search / status / channelType / tags / paging), `findByChannelValue`,
`findByIds`, `countByTenant`.
- Channel-typed list filter — `channels[WHERE type = $type]` uses
Surreal's array projection so filtering is index-pushable.
- Date hydration — string `createdAt` / `updatedAt` values returned by
SurrealDB are parsed into native `Date` instances.
- Bare ids (`abc`) are auto-prefixed with `contact:` so callers can pass
either form.
- Failures are wrapped in the typed `ContactsApiError` so HTTP handlers
branch on `error.code`.
## Usage: Basic
```ts
import { SurrealAdapter } from "@suaveplan/surrealdb";
import { SurrealContactRepository } from "@suaveplan/api-plugin-contacts";
const adapter = new SurrealAdapter({ url, namespace, database });
await adapter.connect();
const repo = new SurrealContactRepository(adapter);
const c = await repo.create({
tenantId: "tenant-1",
displayName: "Ada Lovelace",
channels: [
{ type: "email", value: "ada@example.com", primary: true, verified: false },
],
});
```
## Usage: Advanced
```ts
import { SurrealContactRepository } from "@suaveplan/api-plugin-contacts";
const repo = new SurrealContactRepository(adapter);
// Paginated list with channelType filter:
const page = await repo.list({
tenantId: "tenant-1",
channelType: "email",
search: "@example.com",
limit: 50,
offset: 0,
});
// Bulk fetch by ids:
const byIds = await repo.findByIds(page.contacts.map((c) => c.id));
```
## API Reference
- `class SurrealContactRepository implements ContactStore` — constructor
`(db: SurrealAdapter)`. Public methods:
- `create(input: CreateContactInput): Promise<Contact>`
- `getById(id: string): Promise<Contact | null>`
- `update(id: string, input: UpdateContactInput): Promise<Contact>`
- `delete(id: string): Promise<void>`
- `list(query: ContactQuery): Promise<ContactListResult>`
- `findByChannelValue(tenantId, channelType, channelValue): Promise<Contact | null>`
- `findByIds(ids: readonly string[]): Promise<Contact[]>`
- `countByTenant(tenantId: string): Promise<number>`
## Implementation Notes
- `list` builds the WHERE clause with positional `$name` parameters and
passes them to `SurrealAdapter.query` for prepared-statement style
binding — no string interpolation of user input.
- `findByIds([])` short-circuits with an empty result and never touches
the adapter so callers don't pay for a SurrealDB round-trip on an
empty input.
- `mapContact` strips `null` first/last names before they hit the
returned `Contact` (the type marks them optional), matching the
universal `InMemoryContactStore` shape exactly.

View File

@@ -0,0 +1,91 @@
# repositories/group-repository — SurrealDB-backed GroupStore
## Purpose
Concrete implementation of the `GroupStore` interface from
`@suaveplan/contacts`, backed by `SurrealAdapter`. Implements the
hybrid adjacency-list + materialised-path strategy for hierarchical
groups: every row carries `parentId`, `path`, and `depth`, so subtree
queries become `WHERE string::contains(path, $path)` and lookups of
direct children become `WHERE parentId = $parentId`.
Enforces the plugin's configured `maxGroupDepth` on `createGroup` and
`moveGroup` when `enforceGroupDepth` is true — closing the upstream
gap where the config was advertised but never enforced.
## Features
- Full `GroupStore` surface: `generateId`, `create`, `getById`,
`update`, `delete`, `list`, `getChildren`, `getDescendants`,
`getRoots`, `findByIds`.
- Domain-friendly `createGroup` that computes `path` and `depth` from
the optional parent, plus a low-level `create` that takes a
pre-built `Group`.
- `moveGroup(id, newParentId, tenantId)` — reparent with depth +
path recomputation and depth-cap enforcement.
- Bare ids (`g1`) are auto-prefixed with `contact_group:`.
- Configurable `maxGroupDepth` + `enforceGroupDepth` via constructor
options.
## Usage: Basic
```ts
import { SurrealGroupRepository } from "@suaveplan/api-plugin-contacts";
const repo = new SurrealGroupRepository(adapter, {
maxGroupDepth: 10,
enforceGroupDepth: true,
});
const root = await repo.createGroup({ tenantId: "t", name: "All" });
const child = await repo.createGroup({
tenantId: "t",
name: "Engineering",
parentId: root.id,
});
```
## Usage: Advanced
```ts
import { SurrealGroupRepository } from "@suaveplan/api-plugin-contacts";
const repo = new SurrealGroupRepository(adapter, {
maxGroupDepth: 5,
enforceGroupDepth: true,
idGenerator: () => crypto.randomUUID(),
});
// Reparent — throws GROUP_DEPTH_EXCEEDED if depth would exceed 5:
const moved = await repo.moveGroup(child.id, otherParentId, "t");
// Walk a subtree:
const descendants = await repo.getDescendants(root.path);
```
## API Reference
- `interface SurrealGroupRepositoryOptions` — `{ maxGroupDepth?,
enforceGroupDepth?, idGenerator? }`.
- `class SurrealGroupRepository implements GroupStore` — constructor
`(db: SurrealAdapter, options?: SurrealGroupRepositoryOptions)`.
Adds two domain methods on top of the interface:
- `createGroup(input: CreateGroupInput): Promise<Group>` — computes
path/depth + enforces depth cap.
- `moveGroup(id, newParentId, tenantId): Promise<Group>` —
reparent + depth cap.
## Implementation Notes
- The two-step write (`CREATE` then `UPDATE path`) is used because
SurrealDB assigns the record id at `CREATE` time, and the
materialised path needs the actual id. The temporary id is stripped
in the follow-up patch.
- `getDescendants` uses `string::contains(path, $path) AND path !=
$path` so the root itself is not included in the result set.
- Constructor option `enforceGroupDepth` defaults to `false` so the
bare-class behaviour matches the universal `InMemoryGroupStore`
in `@suaveplan/contacts`; the plugin factory sets it to `true` by
default to enforce the documented config.
- `pickFirst` raises `CREATE_FAILED` when SurrealDB returns no row;
this catches database-side rejections (assertion failure on a NOT
NULL field, for example) that the driver swallowed into an empty
array.

View File

@@ -0,0 +1,73 @@
# repositories/membership-repository — SurrealDB-backed MembershipStore
## Purpose
Concrete implementation of the `MembershipStore` interface
from `@suaveplan/contacts`, backed by `SurrealAdapter`. Persists the
many-to-many `(groupId, contactId)` join with a `UNIQUE` SurrealQL
index that raises `MEMBERSHIP_EXISTS` on duplicate insertion.
The repository owns no business logic — idempotent add semantics are
implemented by the route layer (`POST /groups/:id/members` checks
`exists` before adding so the second call returns `{ added: 0 }`
without surfacing a `MEMBERSHIP_EXISTS` error).
## Features
- Full `MembershipStore` surface: `generateId`, `add`, `remove`,
`getByGroup`, `getByContact`, `exists`, `removeAllForContact`,
`removeAllForGroup`, `getContactIdsByGroups`, `countByGroup`.
- Sniffs SurrealDB unique-index violations and translates them into
`ContactsApiError(MEMBERSHIP_EXISTS)`; other adapter errors become
`CREATE_FAILED` / `DELETE_FAILED`.
- `getContactIdsByGroups([])` short-circuits to avoid a useless
database round-trip.
## Usage: Basic
```ts
import { SurrealMembershipRepository } from "@suaveplan/api-plugin-contacts";
const repo = new SurrealMembershipRepository(adapter);
await repo.add({
id: repo.generateId(),
groupId: "contact_group:engineering",
contactId: "contact:ada",
addedAt: new Date(),
});
```
## Usage: Advanced
```ts
import { SurrealMembershipRepository } from "@suaveplan/api-plugin-contacts";
const repo = new SurrealMembershipRepository(adapter, {
idGenerator: () => crypto.randomUUID(),
});
const contactIds = await repo.getContactIdsByGroups([
"contact_group:engineering",
"contact_group:sales",
]);
const unique = Array.from(new Set(contactIds));
```
## API Reference
- `interface SurrealMembershipRepositoryOptions` — `{ idGenerator? }`.
- `class SurrealMembershipRepository implements MembershipStore` —
constructor `(db: SurrealAdapter, options?)`.
## Implementation Notes
- `add` inspects the thrown error's message for the substrings
`unique`, `UNIQUE`, or `duplicate` to classify a unique-index
violation. SurrealDB's exact message format has drifted between
versions; the substring approach survives the most common variants.
- `exists` uses `count() AS count … GROUP ALL` rather than
`SELECT … LIMIT 1` because `count` is consistently zero-or-one in
SurrealDB and avoids deserialising the row.
- `mapMembership` preserves the original `metadata` field shape —
`undefined` stays `undefined` rather than being coerced to `{}`, so
callers can distinguish "no metadata" from "empty metadata".

View File

@@ -0,0 +1,61 @@
# routes/contacts/build-routes — /contacts route aggregator
## Purpose
Returns the seven `PluginRouteEntry` values that compose the
`/contacts` route tree. Mounted by the plugin's `onInit` via
`definePluginRoutes` from `@suaveplan/api-openapi`. The aggregator is
the single place that names the route order, so `definePluginRoutes`
gets a deterministic shape.
The individual route builders live in `contacts-crud-routes.ts` (list,
create, get, update, delete) and `contacts-bulk-routes.ts` (import,
merge); splitting them keeps each file inside the 250-line soft
budget. The aggregator's only responsibility is to call them in order
and pass through the runtime-bound `maxImportBatchSize` for the import
schema cap.
## Features
- Returns the entries in canonical mounting order: `list`, `create`,
`get`, `update`, `delete`, `import`, `merge`.
- Forwards `maxImportBatchSize` to the import route so the Zod
boundary uses the plugin's runtime config.
## Usage: Basic
```ts
import { buildContactRoutes } from "@suaveplan/api-plugin-contacts/routes/contacts/build-routes";
const entries = buildContactRoutes({ maxImportBatchSize: 1000 });
```
## Usage: Advanced
```ts
import { definePluginRoutes } from "@suaveplan/api-openapi";
import { buildContactRoutes } from "@suaveplan/api-plugin-contacts/routes/contacts/build-routes";
definePluginRoutes(buildContactRoutes({ maxImportBatchSize: 500 }), {
app,
registry: openAPIRegistry,
pluginName: "contacts",
});
```
## API Reference
- `BuildContactRoutesOptions` — `{ maxImportBatchSize: number }`.
- `buildContactRoutes(options): readonly PluginRouteEntry[]` — returns
the 7 contact route entries.
## Implementation Notes
- The function is intentionally pure and synchronous; no `await` or
side effect. All async work happens inside the per-route handlers.
- `maxImportBatchSize` is captured by the import builder when it is
invoked — the Zod schema is minted per plugin-instance so changing
the config across instances does not leak between them.
- The route paths use Hono's `:id` syntax rather than OpenAPI's `{id}`
because `@hono/zod-openapi` translates Hono paths to OpenAPI at
registration time.

View File

@@ -0,0 +1,66 @@
# routes/contacts/contacts-bulk-routes — import + merge
## Purpose
Two `PluginRouteEntry` builders for the bulk-operation contact
endpoints: `POST /contacts/import` and `POST /contacts/merge`. Both
emit OTel histograms (`importDurationMs`, `mergeDurationMs`) carrying
the wall-time of the operation labelled by outcome.
The import builder accepts a runtime `maxImportBatchSize` so the Zod
boundary enforces the cap at parse time (HTTP 400 with `IMPORT_TOO_LARGE`)
rather than after spending CPU on a too-large body.
## Features
- `buildImportContactsRoute(maxImportBatchSize)` — minted per
plugin-instance with the configured cap.
- `buildMergeContactsRoute()` — channel + tag union with primary
contact preserved; secondaries deleted.
- Per-row error reporting for import (`{ index, error }`).
- `skipDuplicates: true` consults `findByChannelValue` on the first
channel of each row.
- Both handlers raise `ContactsApiError` on every failure path; the
host's `onError` middleware maps `statusCode` → HTTP status.
## Usage: Basic
```ts
import { buildImportContactsRoute } from "@suaveplan/api-plugin-contacts/routes/contacts/contacts-bulk-routes";
const entry = buildImportContactsRoute(1000);
```
## Usage: Advanced
```ts
import {
buildImportContactsRoute,
buildMergeContactsRoute,
} from "@suaveplan/api-plugin-contacts/routes/contacts/contacts-bulk-routes";
const entries = [
buildImportContactsRoute(500),
buildMergeContactsRoute(),
];
// definePluginRoutes(entries, …)
```
## API Reference
- `buildImportContactsRoute(maxImportBatchSize: number): PluginRouteEntry`.
- `buildMergeContactsRoute(): PluginRouteEntry`.
## Implementation Notes
- The import handler distinguishes `IMPORT_TOO_LARGE` from generic
`INVALID_INPUT` by inspecting `parsed.error.issues` for any
`code === "too_big"` issue. This means callers see the discriminating
error code rather than a generic 400.
- The merge handler unions channels by `(type, value)` so duplicate
channels from secondaries are silently dropped; primary channels keep
their `primary: true` flag and incoming channels are demoted to
`primary: false`.
- Histograms record the wall-time on both success and failure outcomes;
the failure path records `0` so the distribution of error responses
is visible in the dashboard.

View File

@@ -0,0 +1,69 @@
# routes/contacts/contacts-crud-routes — CRUD route builders
## Purpose
Five `PluginRouteEntry` builders for the contact CRUD endpoints:
`GET /contacts`, `POST /contacts`, `GET /contacts/:id`,
`PATCH /contacts/:id`, `DELETE /contacts/:id`. Each builder produces
an `@suaveplan/api-openapi`-shaped entry that the plugin mounts via
`definePluginRoutes`.
Every handler shares the same skeleton: `requireTenant(c)` for
auth, `c.get("contactRepository")` (typed via the Hono ContextVariableMap
augmentation) for storage, Zod-validate the body, perform the op, and
emit the appropriate OTel instrument.
## Features
- Per-route entry builders: `buildListContactsRoute`,
`buildCreateContactRoute`, `buildGetContactRoute`,
`buildUpdateContactRoute`, `buildDeleteContactRoute`.
- All paths under `/contacts` so the parent route prefix is the
plugin's `routePrefix`.
- OTel: every write increments `contactWritesTotal{outcome}` and every
read increments `contactReadsTotal`.
- All errors thrown — no in-handler `c.json({ success: false })`
branches. The host's `onError` middleware maps `ContactsApiError`
to the right HTTP status via `error.context.statusCode`.
## Usage: Basic
```ts
import { buildListContactsRoute } from "@suaveplan/api-plugin-contacts/routes/contacts/build-routes";
const { route, handler } = buildListContactsRoute();
```
## Usage: Advanced
```ts
import { definePluginRoutes } from "@suaveplan/api-openapi";
import {
buildListContactsRoute,
buildCreateContactRoute,
} from "@suaveplan/api-plugin-contacts/routes/contacts/contacts-crud-routes";
definePluginRoutes(
[buildListContactsRoute(), buildCreateContactRoute()],
{ app, registry, pluginName: "contacts" }
);
```
## API Reference
- `buildListContactsRoute(): PluginRouteEntry` — `GET /contacts`.
- `buildCreateContactRoute(): PluginRouteEntry` — `POST /contacts`.
- `buildGetContactRoute(): PluginRouteEntry` — `GET /contacts/:id`.
- `buildUpdateContactRoute(): PluginRouteEntry` — `PATCH /contacts/:id`.
- `buildDeleteContactRoute(): PluginRouteEntry` — `DELETE /contacts/:id`.
## Implementation Notes
- The handler factory re-parses the body with Zod even though the
RouteBuilder declares the body schema; this guards against callers
who mount the entry on a Hono app without the OpenAPI middleware.
- `getById` failures are categorised as `NOT_FOUND` not `UNAUTHORIZED`
to avoid leaking tenant-existence information across tenants.
- The list handler runs Zod against the raw query strings (rather than
trusting `@hono/zod-openapi`'s automatic decoder) so the bare-mount
test code path exercises the same parse logic.

View File

@@ -0,0 +1,65 @@
# routes/groups/build-routes — /groups route aggregator
## Purpose
Returns the 11 `PluginRouteEntry` values that compose the `/groups`
route tree: 5 CRUD (`list`, `create`, `get`, `update`, `delete`), 2
hierarchy (`tree`, `move`), and 4 membership-and-audience (`list
members`, `add members`, `remove members`, `resolve`). Mounted by the
plugin via `definePluginRoutes`.
The aggregator is purely structural; each route lives in a topic-
focused file (`groups-crud-routes.ts`, `groups-tree-routes.ts`,
`groups-members-routes.ts`) to stay inside the 250-line budget.
## Features
- Returns entries in canonical mounting order.
- Pure / synchronous; no per-request state.
## Usage: Basic
```ts
import { buildGroupRoutes } from "@suaveplan/api-plugin-contacts";
const entries = buildGroupRoutes();
```
## Usage: Advanced
```ts
import { definePluginRoutes } from "@suaveplan/api-openapi";
import { buildGroupRoutes } from "@suaveplan/api-plugin-contacts";
definePluginRoutes(buildGroupRoutes(), {
app,
registry,
pluginName: "contacts",
});
```
## API Reference
- `buildGroupRoutes(): readonly PluginRouteEntry[]` — 11 entries.
## Implementation Notes
- The ordering matters for OpenAPI document generation: nested paths
like `/groups/:id/tree` come after the bare `/groups/:id` entries so
the registry registers the more-specific route last (Hono treats
the entries as registration-ordered).
- The aggregator does not take any options because all per-instance
config (e.g. `maxGroupDepth`) is read from the request context's
`groupRepository`, which is already configured at plugin onInit.
- The 11 entries are split across three topic-focused files
(`groups-crud-routes.ts`, `groups-tree-routes.ts`,
`groups-members-routes.ts`) so each remains inside the 250-line
budget while keeping the aggregator a flat list. Adding a new route
is therefore a single-line change here plus the corresponding
builder in the appropriate topic file.
- Every handler returned by the per-topic builders uses the same
skeleton — `requireTenant(c)` for auth, `c.get("groupRepository")`
(typed via the Hono `ContextVariableMap` augmentation) for storage,
and a `ContactsApiError` throw for every failure path. The host's
error-handler middleware maps `error.context.statusCode` onto the
HTTP response status.

View File

@@ -0,0 +1,67 @@
# routes/groups/groups-crud-routes — CRUD route builders
## Purpose
Five `PluginRouteEntry` builders for the group CRUD endpoints:
`GET /groups`, `POST /groups`, `GET /groups/:id`, `PATCH /groups/:id`,
`DELETE /groups/:id`. Each handler reads the `groupRepository` (and,
for delete, the `membershipRepository`) from the typed Hono
`ContextVariableMap` and emits `groupWritesTotal` for write operations.
`DELETE /groups/:id` cascades through the membership repository so
member rows for the deleted group are removed first; this preserves
the database invariant that a `group_membership` row never points at
a missing group.
## Features
- Per-route entry builders: `buildListGroupsRoute`,
`buildCreateGroupRoute`, `buildGetGroupRoute`,
`buildUpdateGroupRoute`, `buildDeleteGroupRoute`.
- `POST /groups` delegates to `repo.createGroup(...)` so
`maxGroupDepth` enforcement is centralised in the repository.
- `DELETE /groups/:id` removes member rows before the group itself.
- Successful writes emit `groupWritesTotal{outcome="success"}`.
## Usage: Basic
```ts
import { buildCreateGroupRoute } from "@suaveplan/api-plugin-contacts/routes/groups/groups-crud-routes";
const entry = buildCreateGroupRoute();
```
## Usage: Advanced
```ts
import { definePluginRoutes } from "@suaveplan/api-openapi";
import {
buildListGroupsRoute,
buildCreateGroupRoute,
buildGetGroupRoute,
} from "@suaveplan/api-plugin-contacts/routes/groups/groups-crud-routes";
definePluginRoutes(
[buildListGroupsRoute(), buildCreateGroupRoute(), buildGetGroupRoute()],
{ app, registry, pluginName: "contacts" }
);
```
## API Reference
- `buildListGroupsRoute(): PluginRouteEntry` — `GET /groups`.
- `buildCreateGroupRoute(): PluginRouteEntry` — `POST /groups`.
- `buildGetGroupRoute(): PluginRouteEntry` — `GET /groups/:id`.
- `buildUpdateGroupRoute(): PluginRouteEntry` — `PATCH /groups/:id`.
- `buildDeleteGroupRoute(): PluginRouteEntry` — `DELETE /groups/:id`.
## Implementation Notes
- `toResponseGroup` serialises `Date` instances on the way out so the
wire format matches `GroupResponseSchema`. The bare repository
return values are kept as `Date` for downstream use.
- Tenant scoping: every handler re-checks `group.tenantId === tenantId`
after fetching the row so cross-tenant lookups return 404 (not 403);
the upstream port made the same call to avoid leaking existence.
- The cascading membership delete uses `removeAllForGroup`, which
bypasses the per-row uniqueness machinery for speed.

View File

@@ -0,0 +1,71 @@
# routes/groups/groups-members-routes — membership routes
## Purpose
Four `PluginRouteEntry` builders for the group-membership endpoints:
`GET /groups/:id/members`, `POST /groups/:id/members`,
`DELETE /groups/:id/members`, and `GET /groups/:id/resolve` (the
audience-resolution endpoint that flattens a group + its descendants
into a deduplicated contact list).
`POST` and `DELETE` are idempotent: the handlers consult `exists`
before each `add` / `remove` so the second call returns `{ added: 0 }`
or `{ removed: 0 }` rather than surfacing `MEMBERSHIP_EXISTS` /
`NOT_FOUND` from the repository.
## Features
- `buildListMembersRoute()` — list with `total`.
- `buildAddMembersRoute()` — idempotent add; emits
`membershipWritesTotal` with the actual added count.
- `buildRemoveMembersRoute()` — idempotent remove; emits the actual
removed count.
- `buildResolveGroupRoute()` — walks descendants, queries
membership, dedup-fetches contacts.
## Usage: Basic
```ts
import { buildAddMembersRoute } from "@suaveplan/api-plugin-contacts/routes/groups/groups-members-routes";
const entry = buildAddMembersRoute();
```
## Usage: Advanced
```ts
import {
buildListMembersRoute,
buildAddMembersRoute,
buildRemoveMembersRoute,
buildResolveGroupRoute,
} from "@suaveplan/api-plugin-contacts/routes/groups/groups-members-routes";
const entries = [
buildListMembersRoute(),
buildAddMembersRoute(),
buildRemoveMembersRoute(),
buildResolveGroupRoute(),
];
```
## API Reference
- `buildListMembersRoute(): PluginRouteEntry` — `GET /groups/:id/members`.
- `buildAddMembersRoute(): PluginRouteEntry` — `POST /groups/:id/members`.
- `buildRemoveMembersRoute(): PluginRouteEntry` — `DELETE /groups/:id/members`.
- `buildResolveGroupRoute(): PluginRouteEntry` — `GET /groups/:id/resolve`.
## Implementation Notes
- The idempotent add uses `memberRepo.exists(id, contactId)` and
generates membership ids via `memberRepo.generateId()`, so the
database does not need a SurrealQL-side existence check for the
happy path.
- The resolver builds the `allGroupIds` array from the requested group
plus its descendants (returned by `getDescendants(path)`); contact ids
are deduplicated via `new Set(...)` before the bulk `findByIds`
fetch.
- The resolver's response counts `groupsResolved` from
`allGroupIds.length`, not the number of contacts — useful for
audience-size estimation in upstream UIs.

View File

@@ -0,0 +1,67 @@
# routes/groups/groups-tree-routes — hierarchy routes
## Purpose
Two `PluginRouteEntry` builders for the hierarchical-group endpoints:
`GET /groups/:id/tree` materialises a typed `GroupTreeNode` subtree
from the `getDescendants(path)` query, and `POST /groups/:id/move`
reparents a group, enforcing `maxGroupDepth` at the repository layer.
The tree builder uses `buildTree` from `../tree/build-tree` rather
than reimplementing the recursion inline — preserving full type
safety from row to JSON.
## Features
- Tree response is a fully-typed `GroupTreeNode` (no `any`).
- Move handler delegates to `repo.moveGroup(...)` so depth enforcement
is centralised; failures surface as `CONTACTS_API.GROUP_DEPTH_EXCEEDED`.
- Both routes serialise `Date` fields recursively before responding.
## Usage: Basic
```ts
import { buildGroupTreeRoute, buildMoveGroupRoute } from "@suaveplan/api-plugin-contacts/routes/groups/groups-tree-routes";
const tree = buildGroupTreeRoute();
const move = buildMoveGroupRoute();
```
## Usage: Advanced
```ts
// Inside a test:
const res = await app.request("/groups/g1/move", {
method: "POST",
body: JSON.stringify({ newParentId: null }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(200);
```
## API Reference
- `buildGroupTreeRoute(): PluginRouteEntry` — `GET /groups/:id/tree`.
- `buildMoveGroupRoute(): PluginRouteEntry` — `POST /groups/:id/move`.
## Implementation Notes
- The tree wrap function recursively converts `Date` fields. It's
declared as a local closure inside the handler to keep the helper
scoped to a single response.
- `moveGroup` failures (including `GROUP_DEPTH_EXCEEDED`) are caught
and re-thrown after incrementing `groupWritesTotal{outcome="error"}`
so observability matches the success path.
- The tree handler walks the descendant list once into a parent-id
bucket map then recurses; with N descendants the total work is
O(N). The recursion ends at the leaves where `byParent.get(id)`
returns `undefined`, which the `?? []` coalesces to an empty array.
- The move handler delegates depth enforcement to
`SurrealGroupRepository.moveGroup`; both the depth-cap check and
the SurrealDB `UPDATE` happen inside the repository so the route
stays a thin Zod + auth + counter wrapper.
- Tenant scoping: the move repository checks `group.tenantId ===
tenantId` and raises `NOT_FOUND` (not `FORBIDDEN`) on a mismatch
so the response does not leak existence information across
tenants. The tree handler performs the same check before walking
descendants.

View File

@@ -0,0 +1,69 @@
# routes/shared/require-tenant — Principal → tenantId guard
## Purpose
Centralised guard that extracts and validates the request `Principal`
on every `/contacts` and `/groups` handler. Returns a typed
`{ id, tenantId }` pair when the principal is well-formed (non-empty
`tenantId`) and throws `ContactsApiError(UNAUTHORIZED)` mapped to HTTP
401 otherwise.
Replaces the upstream port's repeated four-line `if (!user?.tenantId)
return c.json(...)` pattern (and its `as any` cast on the `user`
context slot). One helper, one error path, one tested invariant.
## Features
- Structural-typing the principal: any `{ id?, tenantId? }` shape is
accepted, so the helper does not depend on a specific Better-Auth
configuration.
- Single, typed error path via `ContactsApiError`.
- Defaults missing `id` to `""` to keep handlers from leaking
`undefined` into downstream calls.
## Usage: Basic
```ts
import { requireTenant } from "@suaveplan/api-plugin-contacts";
async function handler(c: Context) {
const { id, tenantId } = requireTenant(c);
return c.json({ ok: true, tenantId });
}
```
## Usage: Advanced
```ts
import { requireTenant } from "@suaveplan/api-plugin-contacts";
async function listContactsHandler(c: Context) {
const { tenantId } = requireTenant(c);
const repo = c.get("contactRepository");
if (repo === undefined) {
throw new Error("contactRepository missing");
}
const result = await repo.list({ tenantId, limit: 50 });
return c.json({ success: true as const, data: result });
}
```
## API Reference
- `interface TenantPrincipal` — `{ readonly id: string; readonly tenantId: string }`.
- `requireTenant(c: Context): TenantPrincipal` — extracts + validates;
throws `ContactsApiError(CONTACTS_API.UNAUTHORIZED)` with HTTP-401
context on failure.
## Implementation Notes
- The helper reads `c.get("user")` typed as the universal Principal
shape via the `@suaveplan/api-plugin-auth` module augmentation; this
package does not re-augment Hono's variable map for `user` (the auth
plugin owns that slot).
- The four guards (null, undefined, non-string tenantId, empty
tenantId) are exercised by separate `it(...)` blocks so every branch
is covered.
- `id` defaulting to `""` when missing keeps later GDPR audit logs
from carrying `"undefined"` strings; downstream code that needs a
real id should re-check.

View File

@@ -0,0 +1,59 @@
# routes/shared/strip-undefined — drop undefined-valued keys
## Purpose
Bridges Zod's optional-field semantics
(`.optional() → string | undefined`) with the suaveplan
`@suaveplan/contacts` input shapes, which use
`exactOptionalPropertyTypes: true` and therefore forbid the
`| undefined` half of optional members. Returning a shallow copy with
`undefined` keys removed lets the route handlers feed Zod output
directly into `repo.create({ ...stripUndefined(parsed.data), tenantId })`
without losing type safety.
Without this helper every route handler would need to manually
re-build the input object key-by-key, or every Zod schema would have
to redefine its optionality via `.transform()`. The helper is the
focused fix.
## Features
- Shallow — preserves nested undefineds because the consumer Zod
schemas only produce top-level optionals.
- Preserves `null`, `0`, `""`, and `false` (only `undefined` is dropped).
- Pure / type-safe — returns `Partial<T>`-compatible shape.
## Usage: Basic
```ts
import { stripUndefined } from "@suaveplan/api-plugin-contacts";
const parsed = CreateContactSchema.parse(input);
const repoInput = stripUndefined(parsed);
await repo.create({ ...repoInput, tenantId });
```
## Usage: Advanced
```ts
import { stripUndefined } from "@suaveplan/api-plugin-contacts";
function safeMerge<T extends Record<string, unknown>>(base: T, patch: Partial<T>): T {
return { ...base, ...stripUndefined(patch) } as T;
}
```
## API Reference
- `stripUndefined<T>(input: T): { [K in keyof T]?: T[K] }` — shallow
copy with `undefined` keys omitted.
## Implementation Notes
- Uses `Object.entries` so symbol-keyed properties are ignored; route
inputs are always plain objects so this is fine.
- Does not deep-clone — the returned object shares nested references
with the input.
- The return type widens to `Partial<T>` for ergonomics; the runtime
contract is stricter (any non-`undefined` original value flows
through unchanged).

View File

@@ -0,0 +1,74 @@
# schemas/contacts-schemas — request/response Zod schemas for /contacts
## Purpose
Zod schemas mounted on every `/contacts` route via
`@hono/zod-openapi`. They validate inbound bodies / query strings,
shape outbound JSON responses, and feed the generated `/openapi.json`
document so the API surface is self-describing without hand-written
OpenAPI YAML.
Splitting these schemas out of the route file means they can be
consumed by adjacent generators (TypeScript SDKs, JSON Schema dumpers,
fixture builders) without pulling in the Hono runtime.
## Features
- Boundary-typed inputs: `CreateContactSchema`, `UpdateContactSchema`,
`ContactQuerySchema`, `MergeContactsSchema`, plus
`buildBulkImportSchema(maxBatchSize)` for the size-bounded import.
- Boundary-typed outputs: `ContactResponseSchema`,
`ContactListResponseSchema`, `BulkImportResponseSchema`,
`MergeResponseSchema`.
- `ContactChannelSchema` defaults `primary` and `verified` to `false`
to match the SurrealQL schema defaults.
- `ContactQuerySchema` coerces query-string numeric values (`limit`,
`offset`) so callers can use plain strings.
## Usage: Basic
```ts
import { CreateContactSchema } from "@suaveplan/api-plugin-contacts/schemas/contacts-schemas";
const body = CreateContactSchema.parse({
displayName: "Ada",
channels: [{ type: "email", value: "ada@example.com" }],
});
```
## Usage: Advanced
```ts
import { buildBulkImportSchema } from "@suaveplan/api-plugin-contacts/schemas/contacts-schemas";
const schema = buildBulkImportSchema(500);
const result = schema.safeParse({ contacts: rows, skipDuplicates: true });
if (!result.success) {
// 400 with Zod issues
}
```
## API Reference
- `ContactChannelSchema`, `ContactStatusSchema` — leaf primitives.
- `CreateContactSchema`, `UpdateContactSchema`, `ContactQuerySchema`,
`MergeContactsSchema` — request bodies / query params.
- `ContactResponseSchema`, `ContactListResponseSchema`,
`BulkImportResponseSchema`, `MergeResponseSchema` — response shapes.
- `buildBulkImportSchema(maxBatchSize)` — factory that returns the
per-instance bulk-import body schema with the supplied cap.
- `BulkImportSchema` — pre-built variant at the default cap (1000) used
for OpenAPI introspection.
## Implementation Notes
- Per-instance bulk-import schemas are built inside
`createContactsPlugin` so the runtime `maxImportBatchSize` config
value drives Zod's `.max()`. This means rejection happens at the
schema boundary (HTTP 400 with Zod issues) rather than inside the
handler.
- Response schemas treat dates as ISO strings; repositories serialise
`Date` instances into ISO format on the way out.
- Empty-object metadata is permitted (`z.record(z.string(),
z.unknown())`) to allow tenants to extend the contact shape without
breaking the schema.

View File

@@ -0,0 +1,78 @@
# schemas/groups-schemas — request/response Zod schemas for /groups
## Purpose
Zod schemas for the seven `/groups` routes (list, create, get, update,
delete, tree, move) plus the three `/groups/:id/members` and the
`/groups/:id/resolve` audience endpoint. Mounted on `OpenAPIHono` via
`@hono/zod-openapi` so request validation, response shaping, and
`/openapi.json` document generation share one declaration.
The recursive `GroupTreeNodeSchema` mirrors the `GroupTreeNode` type
from `@suaveplan/contacts`; the lazy-self-reference pattern is the
canonical way to express a recursive shape in Zod.
## Features
- Inputs: `CreateGroupSchema`, `UpdateGroupSchema`, `GroupQuerySchema`,
`MoveGroupSchema`, `AddMembersSchema`, `RemoveMembersSchema`.
- Outputs: `GroupResponseSchema`, `GroupListResponseSchema`,
`GroupTreeNodeSchema`, `MembersListResponseSchema`,
`ResolvedAudienceSchema`, `MembershipResponseSchema`.
- `AddMembersSchema` / `RemoveMembersSchema` cap batch size at 500
contact ids per request to keep a single round-trip predictable.
- `MoveGroupSchema` accepts an explicit `null` parent for root reparent.
- `GroupQuerySchema` coerces numeric query parameters.
## Usage: Basic
```ts
import { CreateGroupSchema } from "@suaveplan/api-plugin-contacts/schemas/groups-schemas";
const body = CreateGroupSchema.parse({
name: "Engineering",
description: "all engineers",
});
```
## Usage: Advanced
```ts
import {
AddMembersSchema,
GroupTreeNodeSchema,
} from "@suaveplan/api-plugin-contacts/schemas/groups-schemas";
const batch = AddMembersSchema.parse({
contactIds: ["c1", "c2", "c3"],
});
function walk(node: import("zod").infer<typeof GroupTreeNodeSchema>) {
yield node.group.id;
for (const child of node.children) yield* walk(child);
}
```
## API Reference
- `CreateGroupSchema`, `UpdateGroupSchema`, `GroupQuerySchema`,
`MoveGroupSchema`, `AddMembersSchema`, `RemoveMembersSchema` —
request shapes.
- `GroupResponseSchema`, `GroupListResponseSchema`,
`MembersListResponseSchema`, `ResolvedAudienceSchema`,
`MembershipResponseSchema` — response shapes.
- `GroupTreeNodeSchema` — recursive subtree node; pin `GroupTreeNode`
from `@suaveplan/contacts` at the type level.
- `GroupTreeNodeResponse` — inferred runtime shape, exported so route
builders can avoid `z.infer` boilerplate.
## Implementation Notes
- `parentId` is consistently nullable (not optional + null) so the wire
format always carries an explicit value, simplifying downstream JSON
diffing.
- `GroupTreeNodeSchema` is declared with `z.ZodType<GroupTreeNodeResponse>`
to keep TypeScript happy with the recursive reference inside a
`z.lazy(…)`.
- `description.max(1000)` mirrors the SurrealQL field shape; longer
blobs belong in `metadata`.

View File

@@ -0,0 +1,66 @@
# schemas/options — plugin options Zod schema
## Purpose
Zod schema that validates the optional bag of options passed to
`createContactsPlugin`. Every field is optional with a documented
default so consumers can call `createContactsPlugin()` with no
arguments and still receive a fully-resolved `ContactsPluginConfig`.
Validation runs at construction time inside `createContactsPlugin`:
parse failures become `ContactsApiError(CONTACTS_API.INVALID_INPUT)`
with the underlying `ZodError` on `cause`. The plugin therefore fails
fast at boot rather than at first request.
## Features
- Defaults applied at parse time (`routePrefix: "/"`,
`maxImportBatchSize: 1000`, `maxGroupDepth: 10`,
`enforceGroupDepth: true`, `applySchemaOnInit: true`).
- Numeric guards (`int().positive()`) on `maxGroupDepth` and
`maxImportBatchSize`.
- Logger shape validated structurally — any `LoggerLike` with the four
level methods is accepted.
- `routePrefix` requires a leading slash to keep route-mount math
predictable.
## Usage: Basic
```ts
import { ContactsPluginOptionsSchema } from "@suaveplan/api-plugin-contacts/schemas/options";
const cfg = ContactsPluginOptionsSchema.parse({ maxGroupDepth: 5 });
```
## Usage: Advanced
```ts
import { ContactsPluginOptionsSchema } from "@suaveplan/api-plugin-contacts/schemas/options";
const result = ContactsPluginOptionsSchema.safeParse(userSuppliedJson);
if (!result.success) {
throw new Error(
`bad contacts-plugin config: ${result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join(", ")}`
);
}
const cfg = result.data;
```
## API Reference
- `ContactsPluginOptionsSchema` — the validating Zod schema.
- `ContactsPluginOptionsInput` — pre-parse input type (`z.input`).
- `ContactsPluginOptionsParsed` — post-parse output type (`z.output`),
identical in shape to `ContactsPluginConfig`.
## Implementation Notes
- The schema is intentionally `.passthrough()`-free: unknown keys are
stripped to keep the descriptor's `defaults` slot deterministic.
- The logger sub-schema uses `z.function()` rather than `z.any()` so
obvious mis-supplied loggers (`{ info: 123 }`) fail at parse time.
- Adding a field requires updating both this schema and
`ContactsPluginConfig` in `../types/types.ts`; the spec-conformance
test pins that the keys stay in lock-step.

View File

@@ -0,0 +1,73 @@
# services/registry-augmentation — ServiceRegistry slot typing
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Adds typed `contactRepository`, `groupRepository`, and
`membershipRepository` slots to the shared
`@suaveplan/api-plugin/service-registry` `ServiceRegistry` interface so
sibling plugins (campaigns, audience-engine, segments) can resolve the
contacts repositories via `ctx.getService("contactRepository")` with no
cast. Also redeclares host-side slots (`app`, `db`, `authService`,
`migrationRunner`, `openAPIRegistry`, `logger`, `eventBus`, `clock`) so
the contacts plugin's own `onInit` resolves them with full typing even
when the plugin is imported in isolation.
This module emits zero runtime code; it is imported for side effects
only by the package root barrel.
## Features
- Adds three new typed `ServiceRegistry` slots.
- Re-declares host slots for type-safe `ctx.getService` resolution.
- Sibling plugins automatically pick up the augmentation when they
import `@suaveplan/api-plugin-contacts`.
## Usage: Basic
```ts
import "@suaveplan/api-plugin-contacts"; // brings in the augmentation
import type { PluginContext } from "@suaveplan/api-plugin";
async function downstream(ctx: PluginContext) {
const repo = ctx.getService("contactRepository");
// repo is SurrealContactRepository | undefined.
}
```
## Usage: Advanced
```ts
import "@suaveplan/api-plugin-contacts";
import type { PluginContext } from "@suaveplan/api-plugin";
export function buildAudienceService(ctx: PluginContext) {
const groupRepo = ctx.getService("groupRepository");
const memberRepo = ctx.getService("membershipRepository");
if (groupRepo === undefined || memberRepo === undefined) {
throw new Error(
"contacts plugin must be loaded before audience plugin"
);
}
return { groupRepo, memberRepo };
}
```
## API Reference
Module augmentation only — no runtime symbols.
## Implementation Notes
- The host slots (`app`, `db`, `authService`, etc.) are declared as
required on the augmented interface; TypeScript's interface-merging
semantics intersect them across plugins, so a slot declared optional
in one plugin and required in another resolves to required.
- The `export {}` line keeps this module file an ES module rather than
a global script.
- The companion test file is intentionally absent — the augmentation
is a pure type-level construct and is exercised by every test that
calls `ctx.getService(...)`.

View File

@@ -0,0 +1,70 @@
# tree/build-tree — typed subtree builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Constructs a typed `GroupTreeNode` rooted at the supplied `Group`,
given a flat list of descendants in arbitrary order. Replaces the
upstream port's untyped `function buildTree(root: any, descendants:
any[]): any` and aligns the output shape exactly with the
`GroupTreeNode` interface from `@suaveplan/contacts`.
Used by the `GET /groups/:id/tree` route to materialise a subtree from
the result of `GroupStore.getDescendants(path)`.
## Features
- Type-safe in and out — no `any`, no casts.
- O(N) construction via a `Map<parentId, Group[]>` bucket; recursion
is linear in the number of descendants.
- Tolerates orphan descendants (whose `parentId` does not appear in
the input) by silently dropping them, matching the upstream
behaviour while making it explicit in the contract.
## Usage: Basic
```ts
import { buildTree } from "@suaveplan/api-plugin-contacts/tree/build-tree";
const tree = buildTree(rootGroup, descendantGroups);
```
## Usage: Advanced
```ts
import { buildTree } from "@suaveplan/api-plugin-contacts/tree/build-tree";
const sorted = [...descendantGroups].sort((a, b) =>
a.name.localeCompare(b.name)
);
const tree = buildTree(rootGroup, sorted);
// Determinism: with the descendants pre-sorted, child arrays are
// emitted in name order.
function flatten(node: typeof tree): string[] {
return [
node.group.id,
...node.children.flatMap(flatten),
];
}
const ids = flatten(tree);
```
## API Reference
- `buildTree(root: Group, descendants: readonly Group[]): GroupTreeNode`
— root + flat descendants → nested tree.
## Implementation Notes
- The bucket map keys on `parentId`. Descendants whose `parentId` is
`null` are bucketed under the empty string so they do not collide
with rows whose `parentId` happens to equal an actual group id.
- The function does not sort children; callers that need a stable
emission order should sort the descendants array beforehand.
- Cycle detection is not performed; the underlying SurrealQL schema
guarantees acyclicity by populating the materialised `path` at
write time.

View File

@@ -0,0 +1,74 @@
# types — public type bundle
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pure type declarations that downstream consumers of
`@suaveplan/api-plugin-contacts` depend on at compile time. Keeping
them in a dedicated module means importing `ContactsPluginConfig` or
`ContactsPluginOptions` carries no runtime cost — the file emits a
zero-byte `.js` chunk after `vite build`.
This bundle is deliberately small: it covers the two configuration
shapes (`ContactsPluginOptions` accepted by `createContactsPlugin` and
the parsed `ContactsPluginConfig` carried inside the descriptor) and
nothing more. Hono `ContextVariableMap` augmentations live in
`../context/context.ts`; ServiceRegistry augmentations live in
`../services/registry-augmentation.ts`; route Zod schemas live in
`../schemas/`.
## Features
- Optional-input shape (`ContactsPluginOptions`) and parsed-config
shape (`ContactsPluginConfig`) for the plugin factory.
- `readonly`-everywhere for safety inside the descriptor.
- Type-only: zero runtime imports, zero runtime emit.
## Usage: Basic
```ts
import type { ContactsPluginOptions } from "@suaveplan/api-plugin-contacts";
const options: ContactsPluginOptions = { maxGroupDepth: 5 };
```
## Usage: Advanced
```ts
import type {
ContactsPluginConfig,
ContactsPluginOptions,
} from "@suaveplan/api-plugin-contacts";
function buildOverride(
base: ContactsPluginConfig,
override: ContactsPluginOptions
): ContactsPluginConfig {
return {
...base,
...override,
// `enforceGroupDepth` must stay set — the override might omit it.
enforceGroupDepth: override.enforceGroupDepth ?? base.enforceGroupDepth,
};
}
```
## API Reference
- `ContactsPluginOptions` — `Partial`-shaped input to
`createContactsPlugin`. Every field is optional and defaulted by
`ContactsPluginOptionsSchema`.
- `ContactsPluginConfig` — frozen post-parse config that travels in
`PluginDescriptor.defaults` and is handed back into `onInit` via
`ctx.config`.
## Implementation Notes
- This module imports `LoggerLike` from `@suaveplan/types` purely as a
type — no runtime dep is added to the bundle.
- The shapes line up 1:1 with `ContactsPluginOptionsSchema.shape` in
`../schemas/options.ts`; any field added to the schema must also be
added here. The spec-conformance test pins that the two stay in sync.