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,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,106 @@
{
"name": "@suaveplan/api-plugin-contacts",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./plugin/plugin": {
"import": { "types": "./dist/plugin/plugin.d.ts", "default": "./dist/plugin/plugin.js" }
},
"./error-codes/error-codes": {
"import": { "types": "./dist/error-codes/error-codes.d.ts", "default": "./dist/error-codes/error-codes.js" }
},
"./otel/instruments": {
"import": { "types": "./dist/otel/instruments.d.ts", "default": "./dist/otel/instruments.js" }
},
"./schemas/contacts-schemas": {
"import": { "types": "./dist/schemas/contacts-schemas.d.ts", "default": "./dist/schemas/contacts-schemas.js" }
},
"./schemas/groups-schemas": {
"import": { "types": "./dist/schemas/groups-schemas.d.ts", "default": "./dist/schemas/groups-schemas.js" }
},
"./schemas/options": {
"import": { "types": "./dist/schemas/options.d.ts", "default": "./dist/schemas/options.js" }
},
"./repositories/contact-repository": {
"import": { "types": "./dist/repositories/contact-repository.d.ts", "default": "./dist/repositories/contact-repository.js" }
},
"./repositories/group-repository": {
"import": { "types": "./dist/repositories/group-repository.d.ts", "default": "./dist/repositories/group-repository.js" }
},
"./repositories/membership-repository": {
"import": { "types": "./dist/repositories/membership-repository.d.ts", "default": "./dist/repositories/membership-repository.js" }
},
"./routes/contacts/build-routes": {
"import": { "types": "./dist/routes/contacts/build-routes.d.ts", "default": "./dist/routes/contacts/build-routes.js" }
},
"./routes/groups/build-routes": {
"import": { "types": "./dist/routes/groups/build-routes.d.ts", "default": "./dist/routes/groups/build-routes.js" }
},
"./tree/build-tree": {
"import": { "types": "./dist/tree/build-tree.d.ts", "default": "./dist/tree/build-tree.js" }
},
"./types/types": {
"import": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@hono/zod-openapi": "catalog:server",
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-migrations": "workspace:^",
"@suaveplan/api-openapi": "workspace:^",
"@suaveplan/api-plugin": "workspace:^",
"@suaveplan/api-plugin-auth": "workspace:^",
"@suaveplan/contacts": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/surrealdb": "workspace:^",
"@suaveplan/types": "workspace:^",
"hono": "catalog:",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "catalog:observability",
"@suaveplan/api-plugin-database": "workspace:^",
"@suaveplan/better-auth-surrealdb": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@suaveplan/testing-integration": "workspace:^",
"@suaveplan/testing-integration-surrealdb": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"better-auth": "catalog:auth",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "surql", "README.md"]
}

View File

@@ -0,0 +1,170 @@
/**
* Spec-conformance pins for `@suaveplan/api-plugin-contacts`.
* Asserts the public surface, error-code coverage, route topology,
* OTel instrument names, and ServiceRegistry / ContextVariableMap
* augmentations.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
buildContactRoutes,
buildGroupRoutes,
CONTACTS_API_CODES,
ContactsApiError,
type ContactsApiErrorCode,
createContactsPlugin,
getContactsApiInstruments,
resetContactsApiInstruments,
SurrealContactRepository,
SurrealGroupRepository,
SurrealMembershipRepository,
} from "./index.js";
const PKG_ROOT = join(import.meta.dirname, "..");
describe("api-plugin-contacts spec conformance — public surface", () => {
it("exports the plugin factory + the three repositories", {
timeout: 5000,
}, () => {
expect(typeof createContactsPlugin).toBe("function");
expect(SurrealContactRepository).toBeDefined();
expect(SurrealGroupRepository).toBeDefined();
expect(SurrealMembershipRepository).toBeDefined();
});
it("descriptor advertises name, dependencies, version, tags", {
timeout: 5000,
}, () => {
const d = createContactsPlugin();
expect(d.name).toBe("contacts");
expect(d.dependencies).toEqual(["database", "auth"]);
expect(d.version).toBe("0.1.0");
expect(d.tags).toEqual(["contacts", "groups", "audience"]);
});
});
describe("api-plugin-contacts spec conformance — error codes", () => {
it("CONTACTS_API_CODES registers exactly 12 stable codes", {
timeout: 5000,
}, () => {
expect(Object.keys(CONTACTS_API_CODES)).toHaveLength(12);
});
it("every error code is thrown by at least one production path", {
timeout: 5000,
}, () => {
const codes = Object.keys(CONTACTS_API_CODES) as ContactsApiErrorCode[];
const srcFiles = [
"repositories/contact-repository.ts",
"repositories/group-repository.ts",
"repositories/membership-repository.ts",
"routes/contacts/contacts-crud-routes.ts",
"routes/contacts/contacts-bulk-routes.ts",
"routes/groups/groups-crud-routes.ts",
"routes/groups/groups-tree-routes.ts",
"routes/groups/groups-members-routes.ts",
"routes/shared/require-tenant.ts",
"plugin/plugin.ts",
"plugin/schema.ts",
];
const allSource = srcFiles
.map((f) => readFileSync(join(PKG_ROOT, "src", f), "utf8"))
.join("\n");
for (const code of codes) {
expect(allSource.includes(code), `code ${code} not used`).toBe(
true
);
}
});
it("ContactsApiError extends SuaveplanError lineage", {
timeout: 5000,
}, () => {
const err = new ContactsApiError(CONTACTS_API_CODES.NOT_FOUND, "x");
expect(err.code).toBe(CONTACTS_API_CODES.NOT_FOUND);
expect(err.contactsApiCode).toBe(CONTACTS_API_CODES.NOT_FOUND);
});
});
describe("api-plugin-contacts spec conformance — OTel", () => {
it("the six instruments are bound on first call", { timeout: 5000 }, () => {
resetContactsApiInstruments();
const inst = getContactsApiInstruments();
expect(Object.keys(inst).sort()).toEqual(
[
"contactReadsTotal",
"contactWritesTotal",
"groupWritesTotal",
"importDurationMs",
"membershipWritesTotal",
"mergeDurationMs",
].sort()
);
});
});
describe("api-plugin-contacts spec conformance — routes", () => {
it("exposes 7 /contacts routes and 11 /groups routes", {
timeout: 5000,
}, () => {
const contacts = buildContactRoutes({ maxImportBatchSize: 100 });
const groups = buildGroupRoutes();
expect(contacts).toHaveLength(7);
expect(groups).toHaveLength(11);
});
it("contacts routes cover the documented endpoints", {
timeout: 5000,
}, () => {
const paths = buildContactRoutes({ maxImportBatchSize: 100 }).map(
(e) => `${e.route.method.toUpperCase()} ${e.route.path}`
);
expect(paths).toContain("GET /contacts");
expect(paths).toContain("POST /contacts");
expect(paths).toContain("GET /contacts/:id");
expect(paths).toContain("PATCH /contacts/:id");
expect(paths).toContain("DELETE /contacts/:id");
expect(paths).toContain("POST /contacts/import");
expect(paths).toContain("POST /contacts/merge");
});
it("groups routes cover the documented endpoints", {
timeout: 5000,
}, () => {
const paths = buildGroupRoutes().map(
(e) => `${e.route.method.toUpperCase()} ${e.route.path}`
);
expect(paths).toContain("GET /groups");
expect(paths).toContain("POST /groups");
expect(paths).toContain("GET /groups/:id");
expect(paths).toContain("PATCH /groups/:id");
expect(paths).toContain("DELETE /groups/:id");
expect(paths).toContain("GET /groups/:id/tree");
expect(paths).toContain("POST /groups/:id/move");
expect(paths).toContain("GET /groups/:id/members");
expect(paths).toContain("POST /groups/:id/members");
expect(paths).toContain("DELETE /groups/:id/members");
expect(paths).toContain("GET /groups/:id/resolve");
});
});
describe("api-plugin-contacts spec conformance — augmentations", () => {
it("ServiceRegistry augments contactRepository, groupRepository, membershipRepository slots", {
timeout: 5000,
}, () => {
// Compile-time only: this test reading the registry-augmentation
// file is the static evidence; runtime check just confirms the
// module loads without throwing.
const src = readFileSync(
join(PKG_ROOT, "src", "services", "registry-augmentation.ts"),
"utf8"
);
expect(src).toContain("contactRepository: SurrealContactRepository");
expect(src).toContain("groupRepository: SurrealGroupRepository");
expect(src).toContain(
"membershipRepository: SurrealMembershipRepository"
);
});
});

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,19 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import "./context.js";
describe("ContextVariableMap augmentation", () => {
it("ContextVariableMap exposes contactRepository slot typed as SurrealContactRepository", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.get("/probe", (c) => {
// The compile-time guarantee is what matters; at runtime we
// verify `c.set` accepts the value without throwing.
c.set("contactRepository", undefined as never);
return c.text("ok");
});
const res = await app.request("/probe");
expect(res.status).toBe(200);
});
});

View File

@@ -0,0 +1,22 @@
/**
* Hono `ContextVariableMap` module augmentation. Importing this module
* for side effects (which the package root barrel does automatically)
* adds typed `contactRepository`, `groupRepository`,
* `membershipRepository`, and `eventBus` slots to every Hono handler
* inside this plugin's route trees. Removes the upstream port's ~30
* `as any` casts.
*/
import type { EventBusLike } from "@suaveplan/types";
import type { SurrealContactRepository } from "../repositories/contact-repository.js";
import type { SurrealGroupRepository } from "../repositories/group-repository.js";
import type { SurrealMembershipRepository } from "../repositories/membership-repository.js";
declare module "hono" {
interface ContextVariableMap {
contactRepository: SurrealContactRepository;
groupRepository: SurrealGroupRepository;
membershipRepository: SurrealMembershipRepository;
eventBus: EventBusLike;
}
}

View File

@@ -0,0 +1 @@
export {} from "./context.js";

File diff suppressed because it is too large Load Diff

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,50 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CONTACTS_API_CODES, ContactsApiError } from "./error-codes.js";
describe("CONTACTS_API_CODES", () => {
it("every code is a member of the CONTACTS_API namespace", {
timeout: 5000,
}, () => {
for (const value of Object.values(CONTACTS_API_CODES)) {
expect(value.startsWith("CONTACTS_API.")).toBe(true);
}
});
it("registers exactly 12 codes", { timeout: 5000 }, () => {
expect(Object.keys(CONTACTS_API_CODES)).toHaveLength(12);
});
});
describe("ContactsApiError", () => {
it("ContactsApiError preserves cause", { timeout: 5000 }, () => {
const cause = new Error("boom");
const err = new ContactsApiError(
CONTACTS_API_CODES.CREATE_FAILED,
"failed",
{ cause }
);
expect(err.cause).toBe(cause);
expect(err.code).toBe(CONTACTS_API_CODES.CREATE_FAILED);
expect(err.contactsApiCode).toBe(CONTACTS_API_CODES.CREATE_FAILED);
});
it("ContactsApiError carries context", { timeout: 5000 }, () => {
const err = new ContactsApiError(
CONTACTS_API_CODES.NOT_FOUND,
"missing",
{ context: { statusCode: 404, id: "x" } }
);
expect(err.context).toEqual({ statusCode: 404, id: "x" });
});
it("is an instanceof SuaveplanError + ContactsApiError + Error", {
timeout: 5000,
}, () => {
const err = new ContactsApiError(
CONTACTS_API_CODES.UPDATE_FAILED,
"update failed"
);
expect(err).toBeInstanceOf(ContactsApiError);
expect(err).toBeInstanceOf(Error);
});
});

View File

@@ -0,0 +1,70 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-plugin-contacts`.
*
* Every failure path raises a {@link ContactsApiError} carrying one of
* the 12 `CONTACTS_API.*` codes. Consumers branch on the string value
* (`error.code === CONTACTS_API_CODES.NOT_FOUND`).
*
* - `INVALID_INPUT` — `createContactsPlugin(options)` received options
* that failed `ContactsPluginOptionsSchema` validation. `cause`
* holds the `ZodError`.
* - `SERVICE_NOT_AVAILABLE` — `onInit` could not resolve a required
* ServiceRegistry slot (`app`, `db`, `authService`).
* - `SCHEMA_APPLY_FAILED` — bundled SurrealQL schema apply step threw
* at `onInit` (`migrationRunner.run` or the inline `execute` loop).
* - `ROUTE_PREFIX_COLLISION` — configured `routePrefix` collides with
* routes already registered in the host `OpenAPIRegistry`.
* - `NOT_FOUND` — a lookup targeted a record that does not exist or
* belongs to a different tenant.
* - `UNAUTHORIZED` — request hit a `/contacts` or `/groups` handler
* without a valid `Principal.tenantId`.
* - `CREATE_FAILED` — contact, group, or membership create failed at
* the repository layer. `cause` carries the underlying error.
* - `UPDATE_FAILED` — repository `update` failed.
* - `DELETE_FAILED` — repository `delete` failed.
* - `IMPORT_TOO_LARGE` — `POST /contacts/import` body exceeded
* `maxImportBatchSize`. Mapped to HTTP 400.
* - `GROUP_DEPTH_EXCEEDED` — `SurrealGroupRepository.createGroup` or
* `moveGroup` would produce a `depth` exceeding `maxGroupDepth`.
* - `MEMBERSHIP_EXISTS` — `SurrealMembershipRepository.add` raised
* because the unique `(groupId, contactId)` index rejected the row.
*/
export const CONTACTS_API_CODES = defineCodes("CONTACTS_API", [
"INVALID_INPUT",
"SERVICE_NOT_AVAILABLE",
"SCHEMA_APPLY_FAILED",
"ROUTE_PREFIX_COLLISION",
"NOT_FOUND",
"UNAUTHORIZED",
"CREATE_FAILED",
"UPDATE_FAILED",
"DELETE_FAILED",
"IMPORT_TOO_LARGE",
"GROUP_DEPTH_EXCEEDED",
"MEMBERSHIP_EXISTS",
] as const);
/** Discriminated string-literal union of every `CONTACTS_API.*` code. */
export type ContactsApiErrorCode =
(typeof CONTACTS_API_CODES)[keyof typeof CONTACTS_API_CODES];
/**
* Concrete error class for the api-plugin-contacts package. Extends
* {@link SuaveplanError} so `instanceof ContactsApiError` narrows
* `code` to {@link ContactsApiErrorCode}.
*/
export class ContactsApiError extends SuaveplanError<ContactsApiErrorCode> {
public readonly contactsApiCode: ContactsApiErrorCode;
public constructor(
code: ContactsApiErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.contactsApiCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export type { ContactsApiErrorCode } from "./error-codes.js";
export {
CONTACTS_API_CODES,
ContactsApiError,
} from "./error-codes.js";

View File

@@ -0,0 +1,97 @@
/**
* `@suaveplan/api-plugin-contacts` — Contacts and Groups HTTP plugin
* for `@suaveplan/api`. Mounts the `/contacts` and `/groups` route
* trees on a Hono `OpenAPIHono`, backed by SurrealDB implementations
* of the universal `@suaveplan/contacts` store interfaces.
*/
// Side-effect imports — register the error-codes namespace and the
// ServiceRegistry / ContextVariableMap slots for every consumer.
import "./error-codes/error-codes.js";
import "./context/context.js";
import "./services/registry-augmentation.js";
// Error codes
export {
CONTACTS_API_CODES,
ContactsApiError,
type ContactsApiErrorCode,
} from "./error-codes/error-codes.js";
// OpenTelemetry instruments
export {
type ContactsApiInstruments,
getContactsApiInstruments,
resetContactsApiInstruments,
} from "./otel/instruments.js";
// Plugin factory + lifecycle
export {
type ApplyContactsSchemaOptions,
applyContactsSchema,
CONTACT_GROUP_TABLE_DDL,
CONTACT_TABLE_DDL,
CONTACTS_PLUGIN_SCHEMA,
createContactsPlugin,
GROUP_MEMBERSHIP_TABLE_DDL,
runOnInit,
} from "./plugin/index.js";
// Repositories
export {
SurrealContactRepository,
SurrealGroupRepository,
type SurrealGroupRepositoryOptions,
SurrealMembershipRepository,
type SurrealMembershipRepositoryOptions,
} from "./repositories/index.js";
// Routes
export {
type BuildContactRoutesOptions,
buildContactRoutes,
buildGroupRoutes,
requireTenant,
type TenantPrincipal,
} from "./routes/index.js";
// Schemas
export {
AddMembersSchema,
BulkImportResponseSchema,
BulkImportSchema,
buildBulkImportSchema,
ContactChannelSchema,
ContactListResponseSchema,
ContactQuerySchema,
ContactResponseSchema,
ContactStatusSchema,
type ContactsPluginOptionsInput,
type ContactsPluginOptionsParsed,
ContactsPluginOptionsSchema,
CreateContactSchema,
CreateGroupSchema,
GroupListResponseSchema,
GroupQuerySchema,
GroupResponseSchema,
type GroupTreeNodeResponse,
GroupTreeNodeSchema,
MembershipResponseSchema,
MembersListResponseSchema,
MergeContactsSchema,
MergeResponseSchema,
MoveGroupSchema,
RemoveMembersSchema,
ResolvedAudienceSchema,
UpdateContactSchema,
UpdateGroupSchema,
} from "./schemas/index.js";
// Tree utilities
export { buildTree } from "./tree/index.js";
// Public types
export type {
ContactsPluginConfig,
ContactsPluginOptions,
} from "./types/index.js";

View File

@@ -0,0 +1,5 @@
export {
type ContactsApiInstruments,
getContactsApiInstruments,
resetContactsApiInstruments,
} from "./instruments.js";

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,64 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import {
getContactsApiInstruments,
resetContactsApiInstruments,
} from "./instruments.js";
describe("getContactsApiInstruments", () => {
beforeEach(() => {
resetContactsApiInstruments();
});
afterEach(() => {
resetContactsApiInstruments();
});
it("getContactsApiInstruments returns the six-instrument bundle", {
timeout: 5000,
}, () => {
const inst = getContactsApiInstruments();
expect(inst.contactWritesTotal).toBeDefined();
expect(inst.contactReadsTotal).toBeDefined();
expect(inst.groupWritesTotal).toBeDefined();
expect(inst.membershipWritesTotal).toBeDefined();
expect(inst.importDurationMs).toBeDefined();
expect(inst.mergeDurationMs).toBeDefined();
});
it("caches the bundle across repeated calls", { timeout: 5000 }, () => {
const a = getContactsApiInstruments();
const b = getContactsApiInstruments();
expect(a).toBe(b);
});
it("resetContactsApiInstruments rebinds against a freshly installed MeterProvider", {
timeout: 30000,
}, async () => {
resetContactsApiInstruments();
const inst = getContactsApiInstruments();
inst.contactWritesTotal.add(1, { outcome: "success" });
inst.contactReadsTotal.add(2);
inst.groupWritesTotal.add(1, { outcome: "success" });
inst.membershipWritesTotal.add(1, { outcome: "success" });
inst.importDurationMs.record(42, { outcome: "success" });
inst.mergeDurationMs.record(17, { outcome: "success" });
const collected = await flushTestMetrics();
const flat = collected.flatMap((r) =>
r.scopeMetrics.flatMap((s) => s.metrics)
);
const names = flat.map((m) => m.descriptor.name);
expect(names).toContain("contacts_api.contact_writes_total");
expect(names).toContain("contacts_api.contact_reads_total");
expect(names).toContain("contacts_api.group_writes_total");
expect(names).toContain("contacts_api.membership_writes_total");
expect(names).toContain("contacts_api.import_duration_ms");
expect(names).toContain("contacts_api.merge_duration_ms");
});
});

View File

@@ -0,0 +1,93 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* Bundle of OpenTelemetry instruments emitted by the contacts plugin.
*
* - `contactWritesTotal{outcome}` — every contact create / update /
* delete attempt.
* - `contactReadsTotal{outcome}` — every `GET /contacts` or
* `GET /contacts/:id` response.
* - `groupWritesTotal{outcome}` — every group create / update /
* delete / move attempt.
* - `membershipWritesTotal{outcome}` — every add / remove members
* attempt.
* - `importDurationMs` — wall-time histogram of
* `POST /contacts/import` requests, labelled by `outcome`.
* - `mergeDurationMs` — wall-time histogram of
* `POST /contacts/merge` requests, labelled by `outcome`.
*/
export interface ContactsApiInstruments {
readonly contactWritesTotal: Counter;
readonly contactReadsTotal: Counter;
readonly groupWritesTotal: Counter;
readonly membershipWritesTotal: Counter;
readonly importDurationMs: Histogram;
readonly mergeDurationMs: Histogram;
}
let cached: ContactsApiInstruments | undefined;
/**
* Lazily build (and cache) the contacts-plugin instrument bundle
* against the current global `MeterProvider`. Call
* {@link resetContactsApiInstruments} in test setup to force a re-bind
* against a freshly installed provider.
*/
export function getContactsApiInstruments(): ContactsApiInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-plugin-contacts");
cached = {
contactWritesTotal: meter.createCounter(
"contacts_api.contact_writes_total",
{
description:
"Total contact create/update/delete attempts, partitioned by outcome",
}
),
contactReadsTotal: meter.createCounter(
"contacts_api.contact_reads_total",
{ description: "Total successful contact reads" }
),
groupWritesTotal: meter.createCounter(
"contacts_api.group_writes_total",
{
description:
"Total group create/update/delete/move attempts, partitioned by outcome",
}
),
membershipWritesTotal: meter.createCounter(
"contacts_api.membership_writes_total",
{
description:
"Total membership add/remove attempts, partitioned by outcome",
}
),
importDurationMs: meter.createHistogram(
"contacts_api.import_duration_ms",
{
description:
"Wall-time of POST /contacts/import in milliseconds",
unit: "ms",
}
),
mergeDurationMs: meter.createHistogram(
"contacts_api.merge_duration_ms",
{
description:
"Wall-time of POST /contacts/merge in milliseconds",
unit: "ms",
}
),
};
return cached;
}
/**
* Drop the cached instrument bundle so the next
* {@link getContactsApiInstruments} call rebinds against the global
* `MeterProvider` in effect at that point. Tests call this after
* installing a fresh `MeterProvider`.
*/
export function resetContactsApiInstruments(): void {
cached = undefined;
}

View File

@@ -0,0 +1,9 @@
export { createContactsPlugin, runOnInit } from "./plugin.js";
export {
type ApplyContactsSchemaOptions,
applyContactsSchema,
CONTACT_GROUP_TABLE_DDL,
CONTACT_TABLE_DDL,
CONTACTS_PLUGIN_SCHEMA,
GROUP_MEMBERSHIP_TABLE_DDL,
} from "./schema.js";

View File

@@ -0,0 +1,46 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CONTACTS_API_CODES } from "../error-codes/error-codes.js";
import { createContactsPlugin, runOnInit } from "./plugin.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
describe("plugin onInit error propagation", () => {
it("onInit raises SCHEMA_APPLY_FAILED when execute throws", {
timeout: 5000,
}, async () => {
const db = {
async execute() {
throw new Error("db disconnected");
},
async query() {
return [[]];
},
} as unknown as SurrealAdapter;
const app = new OpenAPIHono();
const services = new Map<string, unknown>();
services.set("app", app);
services.set("db", db);
const ctx = createPluginContext({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: {} as never,
services,
});
const desc = createContactsPlugin({ applySchemaOnInit: true });
await expect(
runOnInit(ctx as never, desc.defaults)
).rejects.toMatchObject({
code: CONTACTS_API_CODES.SCHEMA_APPLY_FAILED,
});
});
});

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,243 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../error-codes/error-codes.js";
import { createContactsPlugin, runOnInit } from "./plugin.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function makeFakeDb(): SurrealAdapter {
return {
async execute() {},
async query() {
return [[]];
},
} as unknown as SurrealAdapter;
}
describe("createContactsPlugin", () => {
it("createContactsPlugin returns a typed descriptor with default config", {
timeout: 5000,
}, () => {
const desc = createContactsPlugin();
expect(desc.name).toBe("contacts");
expect(desc.version).toBe("0.1.0");
expect(desc.dependencies).toEqual(["database", "auth"]);
expect(desc.defaults.routePrefix).toBe("/");
expect(desc.defaults.maxImportBatchSize).toBe(1000);
expect(desc.defaults.maxGroupDepth).toBe(10);
expect(desc.defaults.enforceGroupDepth).toBe(true);
expect(desc.defaults.applySchemaOnInit).toBe(true);
});
it("createContactsPlugin throws CONTACTS_API.INVALID_INPUT on invalid options", {
timeout: 5000,
}, () => {
expect(() =>
createContactsPlugin({ maxGroupDepth: -1 } as never)
).toThrowError(ContactsApiError);
});
it("createContactsPlugin honours a custom routePrefix", {
timeout: 5000,
}, () => {
const desc = createContactsPlugin({ routePrefix: "/api" });
expect(desc.defaults.routePrefix).toBe("/api");
});
it("descriptor advertises name, dependencies, version, tags", {
timeout: 5000,
}, () => {
const desc = createContactsPlugin();
expect(desc.tags).toEqual(["contacts", "groups", "audience"]);
});
});
describe("plugin onInit", () => {
function makeContext(
overrides: {
app?: OpenAPIHono | undefined;
db?: SurrealAdapter | undefined;
registry?: ReturnType<typeof createOpenAPIRegistry> | undefined;
} = {}
) {
const app = overrides.app ?? new OpenAPIHono();
const services = new Map<string, unknown>();
if (app !== undefined) services.set("app", app);
if (overrides.db !== undefined) services.set("db", overrides.db);
if (overrides.registry !== undefined)
services.set("openAPIRegistry", overrides.registry);
const ctx = createPluginContext({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: {} as never,
services,
});
return { ctx, app, services };
}
it("throws SERVICE_NOT_AVAILABLE when app missing", {
timeout: 5000,
}, async () => {
const services = new Map<string, unknown>();
// Note: no `app` slot.
services.set("db", makeFakeDb());
const ctx = createPluginContext({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: {} as never,
services,
});
const desc = createContactsPlugin({ applySchemaOnInit: false });
await expect(
runOnInit(ctx as never, desc.defaults)
).rejects.toMatchObject({
code: CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
});
});
it("throws SERVICE_NOT_AVAILABLE when db missing", {
timeout: 5000,
}, async () => {
const { ctx } = makeContext({});
const desc = createContactsPlugin({ applySchemaOnInit: false });
await expect(
runOnInit(ctx as never, desc.defaults)
).rejects.toMatchObject({
code: CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
});
});
it("registers the three repositories in the ServiceRegistry", {
timeout: 5000,
}, async () => {
const db = makeFakeDb();
const { ctx } = makeContext({ db });
const desc = createContactsPlugin({ applySchemaOnInit: false });
await runOnInit(ctx as never, desc.defaults);
expect(ctx.getService("contactRepository")).toBeDefined();
expect(ctx.getService("groupRepository")).toBeDefined();
expect(ctx.getService("membershipRepository")).toBeDefined();
});
it("logs a warning when openAPIRegistry is absent and still mounts handlers", {
timeout: 5000,
}, async () => {
const db = makeFakeDb();
const messages: string[] = [];
const logger = {
debug: () => undefined,
info: () => undefined,
warn: (msg: string) => messages.push(msg),
error: () => undefined,
};
const app = new OpenAPIHono();
const services = new Map<string, unknown>();
services.set("app", app);
services.set("db", db);
const ctx = createPluginContext({
logger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: {} as never,
services,
});
const desc = createContactsPlugin({ applySchemaOnInit: false });
await runOnInit(ctx as never, desc.defaults);
expect(messages.some((m) => m.includes("openAPIRegistry"))).toBe(true);
});
it("registers routes when openAPIRegistry is supplied", {
timeout: 5000,
}, async () => {
const db = makeFakeDb();
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.0.0" },
});
const { ctx } = makeContext({ db, registry });
const desc = createContactsPlugin({ applySchemaOnInit: false });
await runOnInit(ctx as never, desc.defaults);
const paths = registry.getRoutes().map((r) => r.path);
expect(paths).toContain("/contacts");
expect(paths).toContain("/groups");
});
it("raises ROUTE_PREFIX_COLLISION when /contacts already mounted", {
timeout: 5000,
}, async () => {
const db = makeFakeDb();
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.0.0" },
});
const { ctx } = makeContext({ db, registry });
const desc = createContactsPlugin({ applySchemaOnInit: false });
await runOnInit(ctx as never, desc.defaults);
// Second call should detect collision.
await expect(
runOnInit(ctx as never, desc.defaults)
).rejects.toMatchObject({
code: CONTACTS_API_CODES.ROUTE_PREFIX_COLLISION,
});
});
it("applies SurrealQL schema when applySchemaOnInit is true", {
timeout: 5000,
}, async () => {
const executed: string[] = [];
const db = {
async execute(sql: string) {
executed.push(sql);
},
async query() {
return [[]];
},
} as unknown as SurrealAdapter;
const { ctx } = makeContext({ db });
const desc = createContactsPlugin({ applySchemaOnInit: true });
await runOnInit(ctx as never, desc.defaults);
expect(executed.length).toBeGreaterThan(0);
expect(executed[0]).toContain("DEFINE TABLE OVERWRITE contact");
});
});
describe("plugin route prefix overrides", () => {
it("mounts under /api when routePrefix is /api", {
timeout: 5000,
}, async () => {
const db = makeFakeDb();
const app = new OpenAPIHono();
const services = new Map<string, unknown>();
services.set("app", app);
services.set("db", db);
const ctx = createPluginContext({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: {} as never,
services,
});
const desc = createContactsPlugin({
routePrefix: "/api",
applySchemaOnInit: false,
});
await runOnInit(ctx as never, desc.defaults);
// The route was registered onto the bare Hono app (no registry path).
// Probe the app for a known route under /api.
const res = await app.request("/api/contacts");
// Will be 401 (no auth) but indicates handler is mounted.
expect([401, 500]).toContain(res.status);
});
});

View File

@@ -0,0 +1,216 @@
/**
* `createContactsPlugin` — `PluginDescriptor` factory for
* `@suaveplan/api`. Validates options, constructs the three SurrealDB
* repositories, registers them in the host `ServiceRegistry` + Hono
* `ContextVariableMap`, applies the bundled SurrealQL schema, and
* mounts the `/contacts` + `/groups` route trees via
* `definePluginRoutes`.
*/
import type { OpenAPIHono } from "@hono/zod-openapi";
import { definePluginRoutes } from "@suaveplan/api-openapi";
import {
definePlugin,
type PluginContext,
type PluginDescriptor,
} from "@suaveplan/api-plugin";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../error-codes/error-codes.js";
import {
SurrealContactRepository,
SurrealGroupRepository,
SurrealMembershipRepository,
} from "../repositories/index.js";
import { buildContactRoutes } from "../routes/contacts/build-routes.js";
import { buildGroupRoutes } from "../routes/groups/build-routes.js";
import { ContactsPluginOptionsSchema } from "../schemas/options.js";
import type {
ContactsPluginConfig,
ContactsPluginOptions,
} from "../types/types.js";
import { applyContactsSchema } from "./schema.js";
import "../context/context.js";
import "../services/registry-augmentation.js";
/**
* Factory — returns the typed `PluginDescriptor`.
*
* Zod-validates `options` at construction time; invalid options
* surface as `ContactsApiError(INVALID_INPUT)` with the `ZodError`
* carried as `cause`. The descriptor declares
* `dependencies: ["database", "auth"]` so the host topo-sorts after
* `@suaveplan/api-plugin-database` and `@suaveplan/api-plugin-auth`.
*/
export function createContactsPlugin(
options: ContactsPluginOptions = {}
): PluginDescriptor<ContactsPluginConfig> {
const parsed = ContactsPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new ContactsApiError(
CONTACTS_API_CODES.INVALID_INPUT,
"createContactsPlugin received invalid options",
{ cause: parsed.error, context: { statusCode: 500 } }
);
}
const resolved: ContactsPluginConfig = {
routePrefix: parsed.data.routePrefix,
maxImportBatchSize: parsed.data.maxImportBatchSize,
maxGroupDepth: parsed.data.maxGroupDepth,
enforceGroupDepth: parsed.data.enforceGroupDepth,
applySchemaOnInit: parsed.data.applySchemaOnInit,
...(parsed.data.logger !== undefined
? { logger: parsed.data.logger }
: {}),
};
return definePlugin<ContactsPluginConfig>({
name: "contacts",
version: "0.1.0",
description:
"Contacts and groups HTTP plugin for @suaveplan/api. Mounts /contacts and /groups on a Hono OpenAPIHono backed by SurrealDB.",
dependencies: ["database", "auth"],
defaults: resolved,
tags: ["contacts", "groups", "audience"],
onInit: async (ctx) => runOnInit(ctx, resolved),
});
}
export async function runOnInit(
ctx: PluginContext<ContactsPluginConfig>,
config: ContactsPluginConfig
): Promise<void> {
const app = ctx.getService("app");
if (app === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"contacts plugin requires the `app` service in ServiceRegistry",
{ context: { missing: ["app"], statusCode: 500 } }
);
}
const db = ctx.getService("db");
if (db === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"contacts plugin requires the `db` service — load @suaveplan/api-plugin-database first",
{ context: { missing: ["db"], statusCode: 500 } }
);
}
const openAPIRegistry = ctx.getService("openAPIRegistry");
const migrationRunner = ctx.getService("migrationRunner");
const logger = config.logger ?? ctx.logger;
if (config.applySchemaOnInit) {
await applyContactsSchema({
...(migrationRunner !== undefined ? { migrationRunner } : {}),
db,
});
}
const contactRepository = new SurrealContactRepository(db);
const groupRepository = new SurrealGroupRepository(db, {
maxGroupDepth: config.maxGroupDepth,
enforceGroupDepth: config.enforceGroupDepth,
});
const membershipRepository = new SurrealMembershipRepository(db);
ctx.registerService("contactRepository", contactRepository);
ctx.registerService("groupRepository", groupRepository);
ctx.registerService("membershipRepository", membershipRepository);
// Inject the repositories + eventBus into every Hono request context.
const eventBus = ctx.eventBus;
const prefix = config.routePrefix;
const mountPattern = prefix === "/" ? "*" : `${prefix}/*`;
app.use(mountPattern, async (c, next) => {
c.set("contactRepository", contactRepository);
c.set("groupRepository", groupRepository);
c.set("membershipRepository", membershipRepository);
c.set("eventBus", eventBus);
await next();
});
registerRoutes({
app,
openAPIRegistry,
routePrefix: prefix,
maxImportBatchSize: config.maxImportBatchSize,
logger,
});
}
interface RegisterRoutesArgs {
readonly app: OpenAPIHono;
readonly openAPIRegistry: ReturnType<
PluginContext<ContactsPluginConfig>["getService"]
>;
readonly routePrefix: string;
readonly maxImportBatchSize: number;
readonly logger: {
warn: (msg: string, ctx?: Readonly<Record<string, unknown>>) => void;
};
}
function registerRoutes(args: RegisterRoutesArgs): void {
const { app, openAPIRegistry, routePrefix, maxImportBatchSize, logger } =
args;
const entries = [
...buildContactRoutes({ maxImportBatchSize }),
...buildGroupRoutes(),
];
if (openAPIRegistry !== undefined) {
const reg = openAPIRegistry as {
getRoutes: () => Array<{ path: string }>;
};
const collisionPrefix =
routePrefix === "/" ? "/contacts" : `${routePrefix}/contacts`;
const existing = reg
.getRoutes()
.filter((r) => r.path.startsWith(collisionPrefix));
if (existing.length > 0) {
throw new ContactsApiError(
CONTACTS_API_CODES.ROUTE_PREFIX_COLLISION,
`Route prefix '${routePrefix}' collides with previously registered routes`,
{
context: {
prefix: routePrefix,
existing: existing.map((r) => r.path),
statusCode: 500,
},
}
);
}
const prefixedEntries = entries.map((entry) => ({
...entry,
route: {
...entry.route,
path: `${routePrefix === "/" ? "" : routePrefix}${entry.route.path}`,
},
}));
definePluginRoutes(prefixedEntries, {
app,
registry: openAPIRegistry as Parameters<
typeof definePluginRoutes
>[1]["registry"],
pluginName: "contacts",
});
return;
}
logger.warn(
"contacts plugin: openAPIRegistry not registered — routes will not appear in /openapi.json"
);
for (const entry of entries) {
const method = entry.route.method as
| "get"
| "post"
| "put"
| "patch"
| "delete";
const path = `${routePrefix === "/" ? "" : routePrefix}${entry.route.path}`;
app[method](
path,
async (c) => (await entry.handler(c as never)) as Response
);
}
}

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,92 @@
import type { MigrationRunner } from "@suaveplan/api-migrations";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CONTACTS_API_CODES } from "../error-codes/error-codes.js";
import {
applyContactsSchema,
CONTACT_GROUP_TABLE_DDL,
CONTACT_TABLE_DDL,
CONTACTS_PLUGIN_SCHEMA,
GROUP_MEMBERSHIP_TABLE_DDL,
} from "./schema.js";
describe("applyContactsSchema", () => {
it("each table DDL declares its table name", { timeout: 5000 }, () => {
expect(CONTACT_TABLE_DDL).toContain("DEFINE TABLE OVERWRITE contact ");
expect(CONTACT_GROUP_TABLE_DDL).toContain(
"DEFINE TABLE OVERWRITE contact_group "
);
expect(GROUP_MEMBERSHIP_TABLE_DDL).toContain(
"DEFINE TABLE OVERWRITE group_membership "
);
});
it("CONTACTS_PLUGIN_SCHEMA concatenates all three table DDLs", {
timeout: 5000,
}, () => {
expect(CONTACTS_PLUGIN_SCHEMA).toContain("contact_group");
expect(CONTACTS_PLUGIN_SCHEMA).toContain("group_membership");
expect(CONTACTS_PLUGIN_SCHEMA).toContain("OVERWRITE contact ");
});
it("uses migrationRunner when supplied", { timeout: 5000 }, async () => {
const calls: unknown[] = [];
const runner = {
async run(m: unknown) {
calls.push(m);
},
} as unknown as MigrationRunner;
await applyContactsSchema({ migrationRunner: runner });
expect(calls).toHaveLength(1);
});
it("wraps migrationRunner failures in SCHEMA_APPLY_FAILED", {
timeout: 5000,
}, async () => {
const runner = {
async run() {
throw new Error("apply failed");
},
} as unknown as MigrationRunner;
await expect(
applyContactsSchema({ migrationRunner: runner })
).rejects.toMatchObject({
code: CONTACTS_API_CODES.SCHEMA_APPLY_FAILED,
});
});
it("falls back to db.execute when no migrationRunner supplied", {
timeout: 5000,
}, async () => {
const executed: string[] = [];
const db = {
async execute(sql: string) {
executed.push(sql);
},
} as unknown as SurrealAdapter;
await applyContactsSchema({ db });
expect(executed).toHaveLength(1);
expect(executed[0]).toContain("DEFINE TABLE OVERWRITE contact");
});
it("wraps db.execute failures in SCHEMA_APPLY_FAILED", {
timeout: 5000,
}, async () => {
const db = {
async execute() {
throw new Error("connect failed");
},
} as unknown as SurrealAdapter;
await expect(applyContactsSchema({ db })).rejects.toMatchObject({
code: CONTACTS_API_CODES.SCHEMA_APPLY_FAILED,
});
});
it("throws SERVICE_NOT_AVAILABLE when neither transport supplied", {
timeout: 5000,
}, async () => {
await expect(applyContactsSchema({})).rejects.toMatchObject({
code: CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
});
});
});

View File

@@ -0,0 +1,136 @@
/**
* Bundled SurrealQL schema applied at `onInit`. Inlined as a string so
* the package ships a single tree-shakable `.js` bundle with no
* runtime file I/O. The `surql/tables/*.surql` files in the package
* root are the canonical source for editing; this constant mirrors
* them and is verified by the integration test (which executes the
* inlined statements against a real SurrealDB testcontainer).
*/
import type { MigrationRunner } from "@suaveplan/api-migrations";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../error-codes/error-codes.js";
export const CONTACT_TABLE_DDL = `
DEFINE TABLE OVERWRITE contact SCHEMAFULL;
DEFINE FIELD OVERWRITE tenantId ON contact TYPE string ASSERT $value != NONE;
DEFINE FIELD OVERWRITE displayName ON contact TYPE string ASSERT $value != NONE;
DEFINE FIELD OVERWRITE firstName ON contact TYPE option<string>;
DEFINE FIELD OVERWRITE lastName ON contact TYPE option<string>;
DEFINE FIELD OVERWRITE channels ON contact TYPE array<object>;
DEFINE FIELD OVERWRITE channels.*.type ON contact TYPE string;
DEFINE FIELD OVERWRITE channels.*.value ON contact TYPE string;
DEFINE FIELD OVERWRITE channels.*.primary ON contact TYPE bool DEFAULT false;
DEFINE FIELD OVERWRITE channels.*.verified ON contact TYPE bool DEFAULT false;
DEFINE FIELD OVERWRITE tags ON contact TYPE array<string> DEFAULT [];
DEFINE FIELD OVERWRITE status ON contact TYPE string DEFAULT 'active' ASSERT $value IN ['active', 'unsubscribed', 'bounced', 'complained', 'archived'];
DEFINE FIELD OVERWRITE metadata ON contact TYPE object DEFAULT {};
DEFINE FIELD OVERWRITE createdAt ON contact TYPE datetime DEFAULT time::now();
DEFINE FIELD OVERWRITE updatedAt ON contact TYPE datetime VALUE time::now();
DEFINE INDEX OVERWRITE idx_contact_tenant ON contact FIELDS tenantId;
DEFINE INDEX OVERWRITE idx_contact_status ON contact FIELDS tenantId, status;
`;
export const CONTACT_GROUP_TABLE_DDL = `
DEFINE TABLE OVERWRITE contact_group SCHEMAFULL;
DEFINE FIELD OVERWRITE tenantId ON contact_group TYPE string ASSERT $value != NONE;
DEFINE FIELD OVERWRITE name ON contact_group TYPE string ASSERT $value != NONE;
DEFINE FIELD OVERWRITE description ON contact_group TYPE option<string>;
DEFINE FIELD OVERWRITE parentId ON contact_group TYPE option<string>;
DEFINE FIELD OVERWRITE path ON contact_group TYPE string ASSERT $value != NONE;
DEFINE FIELD OVERWRITE depth ON contact_group TYPE int DEFAULT 0;
DEFINE FIELD OVERWRITE metadata ON contact_group TYPE object DEFAULT {};
DEFINE FIELD OVERWRITE contactCount ON contact_group TYPE int DEFAULT 0;
DEFINE FIELD OVERWRITE totalContactCount ON contact_group TYPE int DEFAULT 0;
DEFINE FIELD OVERWRITE createdAt ON contact_group TYPE datetime DEFAULT time::now();
DEFINE FIELD OVERWRITE updatedAt ON contact_group TYPE datetime VALUE time::now();
DEFINE INDEX OVERWRITE idx_group_tenant ON contact_group FIELDS tenantId;
DEFINE INDEX OVERWRITE idx_group_parent ON contact_group FIELDS tenantId, parentId;
DEFINE INDEX OVERWRITE idx_group_path ON contact_group FIELDS path;
`;
export const GROUP_MEMBERSHIP_TABLE_DDL = `
DEFINE TABLE OVERWRITE group_membership SCHEMAFULL;
DEFINE FIELD OVERWRITE groupId ON group_membership TYPE string ASSERT $value != NONE;
DEFINE FIELD OVERWRITE contactId ON group_membership TYPE string ASSERT $value != NONE;
DEFINE FIELD OVERWRITE metadata ON group_membership TYPE object DEFAULT {};
DEFINE FIELD OVERWRITE addedAt ON group_membership TYPE datetime DEFAULT time::now();
DEFINE INDEX OVERWRITE idx_membership_unique ON group_membership FIELDS groupId, contactId UNIQUE;
DEFINE INDEX OVERWRITE idx_membership_group ON group_membership FIELDS groupId;
DEFINE INDEX OVERWRITE idx_membership_contact ON group_membership FIELDS contactId;
`;
/** All-in-one DDL — the three tables in canonical order, separated by newlines. */
export const CONTACTS_PLUGIN_SCHEMA: string = [
CONTACT_TABLE_DDL,
CONTACT_GROUP_TABLE_DDL,
GROUP_MEMBERSHIP_TABLE_DDL,
]
.map((s) => s.trim())
.join("\n\n");
export interface ApplyContactsSchemaOptions {
readonly migrationRunner?: MigrationRunner;
readonly db?: SurrealAdapter;
}
/**
* Apply the bundled DDL. Prefers `migrationRunner` (records the apply
* in the host's tracking table); falls back to `db.execute`. Wraps
* failures in `ContactsApiError(SCHEMA_APPLY_FAILED)`.
*/
export async function applyContactsSchema(
options: ApplyContactsSchemaOptions
): Promise<void> {
const { migrationRunner, db } = options;
if (migrationRunner !== undefined) {
try {
await migrationRunner.run([
{
name: "0001-contacts-plugin-schema",
path: "<inline>",
order: 1,
content: CONTACTS_PLUGIN_SCHEMA,
},
]);
return;
} catch (err) {
throw new ContactsApiError(
CONTACTS_API_CODES.SCHEMA_APPLY_FAILED,
"migrationRunner.run(CONTACTS_PLUGIN_SCHEMA) failed",
{ cause: err as Error, context: { statusCode: 500 } }
);
}
}
if (db !== undefined) {
try {
await db.execute(CONTACTS_PLUGIN_SCHEMA);
return;
} catch (err) {
throw new ContactsApiError(
CONTACTS_API_CODES.SCHEMA_APPLY_FAILED,
"db.execute(CONTACTS_PLUGIN_SCHEMA) failed",
{ cause: err as Error, context: { statusCode: 500 } }
);
}
}
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"applyContactsSchema requires either a migrationRunner or db",
{
context: {
missing: ["migrationRunner", "db"],
statusCode: 500,
},
}
);
}

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,261 @@
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../error-codes/error-codes.js";
import { SurrealContactRepository } from "./contact-repository.js";
interface RecordedCall {
sql: string;
params: readonly unknown[] | undefined;
}
function fakeAdapter(handler: (call: RecordedCall) => unknown): {
adapter: SurrealAdapter;
calls: RecordedCall[];
} {
const calls: RecordedCall[] = [];
const adapter = {
async query(sql: string, params?: readonly unknown[]) {
const call = { sql, params };
calls.push(call);
return handler(call);
},
} as unknown as SurrealAdapter;
return { adapter, calls };
}
const baseRow = {
id: "contact:abc",
tenantId: "t",
displayName: "Ada",
firstName: null,
lastName: null,
channels: [
{ type: "email", value: "a@b.c", primary: true, verified: false },
],
tags: [],
status: "active" as const,
metadata: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
describe("SurrealContactRepository", () => {
it("create + getById roundtrip against fake adapter", {
timeout: 5000,
}, async () => {
const { adapter, calls } = fakeAdapter((call) => {
if (call.sql.includes("CREATE")) return [baseRow];
if (call.sql.includes("SELECT")) return [baseRow];
return [];
});
const repo = new SurrealContactRepository(adapter);
const c1 = await repo.create({
tenantId: "t",
displayName: "Ada",
channels: [
{
type: "email",
value: "a@b.c",
primary: true,
verified: false,
},
],
});
expect(c1.id).toBe("contact:abc");
expect(c1.createdAt).toBeInstanceOf(Date);
const c2 = await repo.getById("contact:abc");
expect(c2?.id).toBe("contact:abc");
expect(calls).toHaveLength(2);
});
it("getById returns null when adapter yields no rows", {
timeout: 5000,
}, async () => {
const { adapter } = fakeAdapter(() => []);
const repo = new SurrealContactRepository(adapter);
expect(await repo.getById("contact:x")).toBeNull();
});
it("create wraps adapter errors in CREATE_FAILED", {
timeout: 5000,
}, async () => {
const adapter = {
async query() {
throw new Error("boom");
},
} as unknown as SurrealAdapter;
const repo = new SurrealContactRepository(adapter);
await expect(
repo.create({
tenantId: "t",
displayName: "x",
channels: [
{
type: "email",
value: "a@b.c",
primary: true,
verified: false,
},
],
})
).rejects.toMatchObject({ code: CONTACTS_API_CODES.CREATE_FAILED });
});
it("update wraps adapter errors in UPDATE_FAILED", {
timeout: 5000,
}, async () => {
const adapter = {
async query() {
throw new Error("nope");
},
} as unknown as SurrealAdapter;
const repo = new SurrealContactRepository(adapter);
await expect(
repo.update("contact:x", { displayName: "y" })
).rejects.toMatchObject({ code: CONTACTS_API_CODES.UPDATE_FAILED });
});
it("delete wraps adapter errors in DELETE_FAILED", {
timeout: 5000,
}, async () => {
const adapter = {
async query() {
throw new Error("nope");
},
} as unknown as SurrealAdapter;
const repo = new SurrealContactRepository(adapter);
await expect(repo.delete("contact:x")).rejects.toMatchObject({
code: CONTACTS_API_CODES.DELETE_FAILED,
});
});
it("list passes filters and limit/offset to the adapter", {
timeout: 5000,
}, async () => {
const { adapter, calls } = fakeAdapter((call) => {
if (call.sql.startsWith("SELECT count")) return [{ count: 0 }];
return [];
});
const repo = new SurrealContactRepository(adapter);
const res = await repo.list({
tenantId: "t",
status: "active",
channelType: "email",
search: "ada",
limit: 25,
offset: 5,
});
expect(res.total).toBe(0);
expect(res.contacts).toEqual([]);
const last = calls[calls.length - 1]!;
expect(last.params).toEqual(["t", "active", "email", "ada", 25, 5]);
expect(last.sql).toContain("ORDER BY createdAt DESC");
});
it("list returns mapped rows", { timeout: 5000 }, async () => {
const row = {
...baseRow,
id: "contact:a",
firstName: "Ada",
lastName: "Lovelace",
tags: ["x"],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-02T00:00:00.000Z",
};
const { adapter } = fakeAdapter((call) => {
if (call.sql.startsWith("SELECT count")) return [{ count: 1 }];
return [row];
});
const repo = new SurrealContactRepository(adapter);
const res = await repo.list({ tenantId: "t" });
expect(res.total).toBe(1);
expect(res.contacts[0]?.firstName).toBe("Ada");
expect(res.contacts[0]?.lastName).toBe("Lovelace");
});
it("findByChannelValue resolves to a contact or null", {
timeout: 5000,
}, async () => {
const row = { ...baseRow, id: "contact:x" };
const { adapter: hit } = fakeAdapter(() => [row]);
const r1 = await new SurrealContactRepository(hit).findByChannelValue(
"t",
"email",
"a@b.c"
);
expect(r1?.id).toBe("contact:x");
const { adapter: miss } = fakeAdapter(() => []);
const r2 = await new SurrealContactRepository(miss).findByChannelValue(
"t",
"email",
"a@b.c"
);
expect(r2).toBeNull();
});
it("findByIds short-circuits empty inputs", { timeout: 5000 }, async () => {
const { adapter, calls } = fakeAdapter(() => []);
const repo = new SurrealContactRepository(adapter);
const res = await repo.findByIds([]);
expect(res).toEqual([]);
expect(calls).toHaveLength(0);
});
it("findByIds maps rows when ids are supplied", {
timeout: 5000,
}, async () => {
const { adapter } = fakeAdapter(() => [
{ ...baseRow, id: "contact:1" },
]);
const repo = new SurrealContactRepository(adapter);
expect((await repo.findByIds(["1"]))[0]?.id).toBe("contact:1");
});
it("countByTenant defaults to 0", { timeout: 5000 }, async () => {
const { adapter } = fakeAdapter(() => []);
const repo = new SurrealContactRepository(adapter);
expect(await repo.countByTenant("t")).toBe(0);
});
it("countByTenant returns the SurrealDB count", {
timeout: 5000,
}, async () => {
const { adapter } = fakeAdapter(() => [{ count: 42 }]);
const repo = new SurrealContactRepository(adapter);
expect(await repo.countByTenant("t")).toBe(42);
});
it("create throws CREATE_FAILED when SurrealDB returns no row", {
timeout: 5000,
}, async () => {
const { adapter } = fakeAdapter(() => []);
const repo = new SurrealContactRepository(adapter);
await expect(
repo.create({
tenantId: "t",
displayName: "x",
channels: [
{
type: "email",
value: "a@b.c",
primary: true,
verified: false,
},
],
})
).rejects.toBeInstanceOf(ContactsApiError);
});
it("update accepts a record id that already contains a colon", {
timeout: 5000,
}, async () => {
const { adapter, calls } = fakeAdapter(() => [baseRow]);
const repo = new SurrealContactRepository(adapter);
await repo.update("contact:abc", { displayName: "Updated" });
const lastCall = calls[calls.length - 1]!;
expect(lastCall.params?.[0]).toBe("abc");
});
});

View File

@@ -0,0 +1,245 @@
/**
* SurrealDB-backed implementation of the genesis `@suaveplan/contacts`
* `ContactStore` interface. Wraps a connected `SurrealAdapter` and maps
* row shapes onto the canonical `Contact` entity (parsing ISO date
* strings into `Date` instances). No mutation of the adapter lifecycle
* — connect / disconnect is the host's responsibility.
*/
import type {
Contact,
ContactChannel,
ContactListResult,
ContactQuery,
ContactStatus,
ContactStore,
CreateContactInput,
UpdateContactInput,
} from "@suaveplan/contacts";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../error-codes/error-codes.js";
interface ContactRow {
readonly id: unknown;
readonly tenantId: string;
readonly displayName: string;
readonly firstName: string | null;
readonly lastName: string | null;
readonly channels: readonly ContactChannel[];
readonly tags: readonly string[];
readonly status: ContactStatus;
readonly metadata: Record<string, unknown>;
readonly createdAt: string | Date;
readonly updatedAt: string | Date;
}
/** SurrealDB-backed contact repository implementing `ContactStore`. */
export class SurrealContactRepository implements ContactStore {
public constructor(private readonly db: SurrealAdapter) {}
public async create(input: CreateContactInput): Promise<Contact> {
try {
// Build the CONTENT object with optional fields omitted when
// unset — SurrealDB's `option<string>` rejects an explicit `null`.
const content: Record<string, unknown> = {
tenantId: input.tenantId,
displayName: input.displayName,
channels: input.channels,
tags: input.tags ?? [],
status: input.status ?? "active",
metadata: input.metadata ?? {},
};
if (input.firstName !== undefined)
content["firstName"] = input.firstName;
if (input.lastName !== undefined)
content["lastName"] = input.lastName;
const result = await this.db.query<ContactRow>(
"CREATE contact CONTENT $p0",
[content]
);
return this.mapContact(pickFirst(result));
} catch (cause) {
if (cause instanceof ContactsApiError) throw cause;
throw new ContactsApiError(
CONTACTS_API_CODES.CREATE_FAILED,
"failed to create contact",
{ cause: cause as Error }
);
}
}
public async getById(id: string): Promise<Contact | null> {
const recordId = this.toRecordId(id);
const result = await this.db.query<ContactRow>(
"SELECT * FROM type::record('contact', $p0)",
[recordId]
);
if (result.length === 0) return null;
return this.mapContact(result[0]!);
}
public async update(
id: string,
input: UpdateContactInput
): Promise<Contact> {
try {
const recordId = this.toRecordId(id);
const result = await this.db.query<ContactRow>(
"UPDATE type::record('contact', $p0) MERGE $p1",
[recordId, input]
);
return this.mapContact(pickFirst(result));
} catch (cause) {
if (cause instanceof ContactsApiError) throw cause;
throw new ContactsApiError(
CONTACTS_API_CODES.UPDATE_FAILED,
`failed to update contact ${id}`,
{ cause: cause as Error, context: { id } }
);
}
}
public async delete(id: string): Promise<void> {
try {
const recordId = this.toRecordId(id);
await this.db.query("DELETE type::record('contact', $p0)", [
recordId,
]);
} catch (cause) {
throw new ContactsApiError(
CONTACTS_API_CODES.DELETE_FAILED,
`failed to delete contact ${id}`,
{ cause: cause as Error, context: { id } }
);
}
}
public async list(query: ContactQuery): Promise<ContactListResult> {
const params: unknown[] = [query.tenantId];
let where = "tenantId = $p0";
if (query.status !== undefined) {
params.push(query.status);
where += ` AND status = $p${params.length - 1}`;
}
if (query.channelType !== undefined) {
params.push(query.channelType);
where += ` AND channels[WHERE type = $p${params.length - 1}].value != NONE`;
}
if (query.search !== undefined) {
params.push(query.search);
const idx = params.length - 1;
where +=
` AND (string::contains(displayName, $p${idx})` +
` OR string::contains(firstName, $p${idx})` +
` OR string::contains(lastName, $p${idx}))`;
}
const limit = query.limit ?? 50;
const offset = query.offset ?? 0;
params.push(limit);
const limitIdx = params.length - 1;
params.push(offset);
const offsetIdx = params.length - 1;
const countResult = await this.db.query<{ count: number }>(
`SELECT count() AS count FROM contact WHERE ${where} GROUP ALL`,
params
);
const total = countResult[0]?.count ?? 0;
const rows = await this.db.query<ContactRow>(
`SELECT * FROM contact WHERE ${where} ORDER BY createdAt DESC LIMIT $p${limitIdx} START $p${offsetIdx}`,
params
);
return {
contacts: rows.map((r) => this.mapContact(r)),
total,
};
}
public async findByChannelValue(
tenantId: string,
channelType: string,
channelValue: string
): Promise<Contact | null> {
const result = await this.db.query<ContactRow>(
`SELECT * FROM contact
WHERE tenantId = $p0
AND channels[WHERE type = $p1 AND value = $p2] != NONE
LIMIT 1`,
[tenantId, channelType, channelValue]
);
if (result.length === 0) return null;
return this.mapContact(result[0]!);
}
public async findByIds(ids: readonly string[]): Promise<Contact[]> {
if (ids.length === 0) return [];
// SurrealDB v3 `type::record` accepts table + id tail; we resolve each id
// individually via parallel queries so the contract works with bare
// ids ("abc") and full record-strings ("contact:abc") alike.
const results = await Promise.all(
ids.map((id) =>
this.db.query<ContactRow>(
"SELECT * FROM type::record('contact', $p0)",
[this.toRecordId(id)]
)
)
);
return results.flat().map((r) => this.mapContact(r));
}
public async countByTenant(tenantId: string): Promise<number> {
const result = await this.db.query<{ count: number }>(
"SELECT count() AS count FROM contact WHERE tenantId = $p0 GROUP ALL",
[tenantId]
);
return result[0]?.count ?? 0;
}
private toRecordId(id: string): string {
// type::record('contact', $tail) — strip any leading "contact:" prefix
// so callers can pass either form.
const idx = id.indexOf(":");
return idx === -1 ? id : id.slice(idx + 1);
}
private mapContact(row: ContactRow): Contact {
const contact: Contact = {
id: String(row.id),
tenantId: row.tenantId,
displayName: row.displayName,
...(row.firstName !== null && row.firstName !== undefined
? { firstName: row.firstName }
: {}),
...(row.lastName !== null && row.lastName !== undefined
? { lastName: row.lastName }
: {}),
channels: row.channels,
tags: row.tags,
status: row.status,
metadata: row.metadata,
createdAt:
row.createdAt instanceof Date
? row.createdAt
: new Date(row.createdAt),
updatedAt:
row.updatedAt instanceof Date
? row.updatedAt
: new Date(row.updatedAt),
};
return contact;
}
}
function pickFirst<T>(rows: readonly T[] | null | undefined): T {
if (!rows || rows.length === 0) {
throw new ContactsApiError(
CONTACTS_API_CODES.CREATE_FAILED,
"SurrealDB returned no row for write operation"
);
}
return rows[0]!;
}

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,371 @@
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../error-codes/error-codes.js";
import { SurrealGroupRepository } from "./group-repository.js";
interface RecordedCall {
sql: string;
params: readonly unknown[] | undefined;
}
function fakeAdapter(handler: (call: RecordedCall) => unknown): {
adapter: SurrealAdapter;
calls: RecordedCall[];
} {
const calls: RecordedCall[] = [];
const adapter = {
async query(sql: string, params?: readonly unknown[]) {
const call = { sql, params };
calls.push(call);
return handler(call);
},
} as unknown as SurrealAdapter;
return { adapter, calls };
}
function row(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: "contact_group:g1",
tenantId: "t",
name: "g",
description: null,
parentId: null,
path: "g1",
depth: 0,
metadata: {},
contactCount: 0,
totalContactCount: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe("SurrealGroupRepository.createGroup", () => {
it("createGroup writes a root group when no parent supplied", {
timeout: 5000,
}, async () => {
const { adapter, calls } = fakeAdapter((call) => {
if (call.sql.startsWith("CREATE")) return [row()];
if (call.sql.startsWith("UPDATE")) return [row()];
return [];
});
const repo = new SurrealGroupRepository(adapter);
const g = await repo.createGroup({ tenantId: "t", name: "g" });
expect(g.depth).toBe(0);
expect(g.parentId).toBeNull();
expect(calls.length).toBe(2);
});
it("createGroup raises GROUP_DEPTH_EXCEEDED past maxGroupDepth", {
timeout: 5000,
}, async () => {
const parent = row({ id: "contact_group:p", depth: 9, path: "p" });
const { adapter } = fakeAdapter(() => [parent]);
const repo = new SurrealGroupRepository(adapter, {
maxGroupDepth: 10,
enforceGroupDepth: true,
});
await expect(
repo.createGroup({
tenantId: "t",
name: "deep",
parentId: "contact_group:p",
})
).rejects.toMatchObject({
code: CONTACTS_API_CODES.GROUP_DEPTH_EXCEEDED,
});
});
it("createGroup permits unlimited depth when enforceGroupDepth is false", {
timeout: 5000,
}, async () => {
const parent = row({ id: "contact_group:p", depth: 100, path: "p" });
const created = row({
id: "contact_group:child",
parentId: "contact_group:p",
depth: 101,
path: "p/temp_group_1",
});
const { adapter } = fakeAdapter((call) => {
if (call.sql.includes("SELECT * FROM type::record"))
return [parent];
if (call.sql.startsWith("CREATE")) return [created];
if (call.sql.startsWith("UPDATE")) return [created];
return [];
});
const repo = new SurrealGroupRepository(adapter, {
maxGroupDepth: 10,
enforceGroupDepth: false,
});
const g = await repo.createGroup({
tenantId: "t",
name: "x",
parentId: "contact_group:p",
});
expect(g.depth).toBe(101);
});
it("createGroup throws NOT_FOUND when parent missing", {
timeout: 5000,
}, async () => {
const { adapter } = fakeAdapter(() => []);
const repo = new SurrealGroupRepository(adapter);
await expect(
repo.createGroup({
tenantId: "t",
name: "x",
parentId: "contact_group:missing",
})
).rejects.toMatchObject({ code: CONTACTS_API_CODES.NOT_FOUND });
});
it("createGroup wraps adapter errors in CREATE_FAILED", {
timeout: 5000,
}, async () => {
const adapter = {
async query() {
throw new Error("boom");
},
} as unknown as SurrealAdapter;
const repo = new SurrealGroupRepository(adapter);
await expect(
repo.createGroup({ tenantId: "t", name: "x" })
).rejects.toMatchObject({ code: CONTACTS_API_CODES.CREATE_FAILED });
});
});
describe("SurrealGroupRepository CRUD + list", () => {
it("getById returns null when no row", { timeout: 5000 }, async () => {
const { adapter } = fakeAdapter(() => []);
const repo = new SurrealGroupRepository(adapter);
expect(await repo.getById("contact_group:x")).toBeNull();
});
it("update wraps adapter errors in UPDATE_FAILED", {
timeout: 5000,
}, async () => {
const adapter = {
async query() {
throw new Error("boom");
},
} as unknown as SurrealAdapter;
const repo = new SurrealGroupRepository(adapter);
await expect(
repo.update("contact_group:x", { name: "y" })
).rejects.toMatchObject({ code: CONTACTS_API_CODES.UPDATE_FAILED });
});
it("delete wraps adapter errors in DELETE_FAILED", {
timeout: 5000,
}, async () => {
const adapter = {
async query() {
throw new Error("boom");
},
} as unknown as SurrealAdapter;
const repo = new SurrealGroupRepository(adapter);
await expect(repo.delete("contact_group:x")).rejects.toMatchObject({
code: CONTACTS_API_CODES.DELETE_FAILED,
});
});
it("create accepts a pre-built Group", { timeout: 5000 }, async () => {
const r = row({ description: "d" });
const { adapter } = fakeAdapter(() => [r]);
const repo = new SurrealGroupRepository(adapter);
const out = await repo.create({
id: "contact_group:g1",
tenantId: "t",
name: "g",
description: "d",
parentId: null,
path: "g1",
depth: 0,
metadata: {},
contactCount: 0,
totalContactCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
});
expect(out.id).toBe("contact_group:g1");
expect(out.description).toBe("d");
});
it("create wraps adapter errors in CREATE_FAILED", {
timeout: 5000,
}, async () => {
const adapter = {
async query() {
throw new Error("boom");
},
} as unknown as SurrealAdapter;
const repo = new SurrealGroupRepository(adapter);
await expect(
repo.create({
id: "contact_group:g1",
tenantId: "t",
name: "g",
parentId: null,
path: "g1",
depth: 0,
metadata: {},
contactCount: 0,
totalContactCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
})
).rejects.toMatchObject({ code: CONTACTS_API_CODES.CREATE_FAILED });
});
it("list applies parentId + search filters and returns mapped rows", {
timeout: 5000,
}, async () => {
const { adapter, calls } = fakeAdapter((call) => {
if (call.sql.startsWith("SELECT count")) return [{ count: 1 }];
return [row()];
});
const repo = new SurrealGroupRepository(adapter);
const res = await repo.list({
tenantId: "t",
parentId: "contact_group:root",
search: "x",
limit: 5,
offset: 0,
});
expect(res.total).toBe(1);
expect(res.groups[0]?.id).toBe("contact_group:g1");
const last = calls[calls.length - 1]!;
expect(last.sql).toContain("parentId = $p1");
expect(last.sql).toContain("ORDER BY name ASC");
});
it("getChildren returns rows", { timeout: 5000 }, async () => {
const { adapter } = fakeAdapter(() => [row()]);
const repo = new SurrealGroupRepository(adapter);
expect((await repo.getChildren("contact_group:root")).length).toBe(1);
});
it("getDescendants returns rows", { timeout: 5000 }, async () => {
const { adapter } = fakeAdapter(() => [row()]);
const repo = new SurrealGroupRepository(adapter);
expect((await repo.getDescendants("root")).length).toBe(1);
});
it("getRoots returns rows", { timeout: 5000 }, async () => {
const { adapter } = fakeAdapter(() => [row()]);
const repo = new SurrealGroupRepository(adapter);
expect((await repo.getRoots("t")).length).toBe(1);
});
it("findByIds short-circuits empty input", { timeout: 5000 }, async () => {
const { adapter, calls } = fakeAdapter(() => []);
const repo = new SurrealGroupRepository(adapter);
expect(await repo.findByIds([])).toEqual([]);
expect(calls).toHaveLength(0);
});
it("findByIds returns mapped rows", { timeout: 5000 }, async () => {
const { adapter } = fakeAdapter(() => [row()]);
const repo = new SurrealGroupRepository(adapter);
expect((await repo.findByIds(["g1"]))[0]?.id).toBe("contact_group:g1");
});
it("generateId yields monotonic ids by default", { timeout: 5000 }, () => {
const { adapter } = fakeAdapter(() => []);
const repo = new SurrealGroupRepository(adapter);
expect(repo.generateId()).toBe("group_1");
expect(repo.generateId()).toBe("group_2");
});
it("generateId honours injected generator", { timeout: 5000 }, () => {
const { adapter } = fakeAdapter(() => []);
const repo = new SurrealGroupRepository(adapter, {
idGenerator: () => "g-x",
});
expect(repo.generateId()).toBe("g-x");
});
});
describe("SurrealGroupRepository.moveGroup", () => {
it("moveGroup raises GROUP_DEPTH_EXCEEDED when over cap", {
timeout: 5000,
}, async () => {
const group = row({ id: "contact_group:g", path: "g", depth: 0 });
const parent = row({ id: "contact_group:p", depth: 10, path: "p" });
const { adapter } = fakeAdapter((call) => {
if (call.params?.[0] === "g") return [group];
if (call.params?.[0] === "p") return [parent];
return [];
});
const repo = new SurrealGroupRepository(adapter, {
maxGroupDepth: 5,
enforceGroupDepth: true,
});
await expect(
repo.moveGroup("contact_group:g", "contact_group:p", "t")
).rejects.toBeInstanceOf(ContactsApiError);
});
it("moveGroup to root resets depth to 0", { timeout: 5000 }, async () => {
const group = row({
id: "contact_group:g",
path: "p/g",
depth: 1,
parentId: "contact_group:p",
});
const updated = row({
id: "contact_group:g",
path: "contact_group:g",
depth: 0,
parentId: null,
});
const { adapter } = fakeAdapter((call) => {
if (call.sql.includes("SELECT")) return [group];
if (call.sql.includes("UPDATE")) return [updated];
return [];
});
const repo = new SurrealGroupRepository(adapter);
const out = await repo.moveGroup("contact_group:g", null, "t");
expect(out.depth).toBe(0);
expect(out.parentId).toBeNull();
});
it("moveGroup throws NOT_FOUND when group missing", {
timeout: 5000,
}, async () => {
const { adapter } = fakeAdapter(() => []);
const repo = new SurrealGroupRepository(adapter);
await expect(
repo.moveGroup("contact_group:missing", null, "t")
).rejects.toMatchObject({ code: CONTACTS_API_CODES.NOT_FOUND });
});
it("moveGroup throws NOT_FOUND when group belongs to a different tenant", {
timeout: 5000,
}, async () => {
const group = row({ id: "contact_group:g", tenantId: "other" });
const { adapter } = fakeAdapter(() => [group]);
const repo = new SurrealGroupRepository(adapter);
await expect(
repo.moveGroup("contact_group:g", null, "t")
).rejects.toMatchObject({ code: CONTACTS_API_CODES.NOT_FOUND });
});
it("moveGroup throws NOT_FOUND when new parent missing", {
timeout: 5000,
}, async () => {
const group = row({ id: "contact_group:g" });
const { adapter } = fakeAdapter((call) => {
if (call.params?.[0] === "g") return [group];
return [];
});
const repo = new SurrealGroupRepository(adapter);
await expect(
repo.moveGroup("contact_group:g", "contact_group:missing", "t")
).rejects.toMatchObject({ code: CONTACTS_API_CODES.NOT_FOUND });
});
});

View File

@@ -0,0 +1,371 @@
/**
* SurrealDB-backed implementation of the genesis `@suaveplan/contacts`
* `GroupStore` interface. Implements hierarchical group nesting via the
* hybrid adjacency-list + materialized-path strategy: every row carries
* `parentId` (adjacency) AND `path` (materialised path) AND `depth`.
*
* Enforces the plugin's configured `maxGroupDepth` on `createGroup` and
* `moveGroup` when `enforceGroupDepth` is `true`.
*/
import type {
CreateGroupInput,
Group,
GroupListResult,
GroupQuery,
GroupStore,
} from "@suaveplan/contacts";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../error-codes/error-codes.js";
/** Patch shape accepted by `GroupStore.update`. Inlined since the
* genesis `@suaveplan/contacts` package does not currently re-export
* `GroupUpdatePatch` from its root barrel. */
export type GroupUpdatePatch = {
-readonly [K in keyof Group]?: Group[K];
};
export interface SurrealGroupRepositoryOptions {
/** Maximum nesting depth. Default `Number.POSITIVE_INFINITY` (no limit). */
readonly maxGroupDepth?: number;
/** When `true`, `createGroup` / `moveGroup` enforce `maxGroupDepth`. */
readonly enforceGroupDepth?: boolean;
/** Optional id generator (defaults to a monotonic counter — only used by `generateId`). */
readonly idGenerator?: () => string;
}
interface GroupRow {
readonly id: unknown;
readonly tenantId: string;
readonly name: string;
readonly description: string | null;
readonly parentId: string | null;
readonly path: string;
readonly depth: number;
readonly metadata: Record<string, unknown>;
readonly contactCount: number;
readonly totalContactCount: number;
readonly createdAt: string | Date;
readonly updatedAt: string | Date;
}
/** SurrealDB-backed group repository implementing `GroupStore`. */
export class SurrealGroupRepository implements GroupStore {
private readonly maxGroupDepth: number;
private readonly enforceGroupDepth: boolean;
private readonly idGenerator: () => string;
private idCounter = 0;
public constructor(
private readonly db: SurrealAdapter,
options: SurrealGroupRepositoryOptions = {}
) {
this.maxGroupDepth = options.maxGroupDepth ?? Number.POSITIVE_INFINITY;
this.enforceGroupDepth = options.enforceGroupDepth ?? false;
this.idGenerator =
options.idGenerator ?? (() => `group_${++this.idCounter}`);
}
public generateId(): string {
return this.idGenerator();
}
public async createGroup(input: CreateGroupInput): Promise<Group> {
let parentPath = "";
let depth = 0;
if (input.parentId !== undefined && input.parentId !== null) {
const parent = await this.getById(input.parentId);
if (parent === null) {
throw new ContactsApiError(
CONTACTS_API_CODES.NOT_FOUND,
`parent group ${input.parentId} not found`,
{ context: { parentId: input.parentId } }
);
}
parentPath = parent.path;
depth = parent.depth + 1;
}
if (this.enforceGroupDepth && depth >= this.maxGroupDepth) {
throw new ContactsApiError(
CONTACTS_API_CODES.GROUP_DEPTH_EXCEEDED,
`group depth ${depth} exceeds maxGroupDepth ${this.maxGroupDepth}`,
{
context: {
depth,
maxGroupDepth: this.maxGroupDepth,
parentId: input.parentId,
},
}
);
}
const tempId = `temp_${this.generateId()}`;
const path = parentPath !== "" ? `${parentPath}/${tempId}` : tempId;
let created: Group;
try {
// Optional fields omitted when unset — SurrealDB's
// `option<string>` rejects explicit nulls. `parentId` is
// included as `null` only when explicitly so requested
// (root group); otherwise omitted.
const content: Record<string, unknown> = {
tenantId: input.tenantId,
name: input.name,
path,
depth,
metadata: input.metadata ?? {},
contactCount: 0,
totalContactCount: 0,
};
if (input.description !== undefined)
content["description"] = input.description;
if (input.parentId !== undefined && input.parentId !== null)
content["parentId"] = input.parentId;
const result = await this.db.query<GroupRow>(
"CREATE contact_group CONTENT $p0",
[content]
);
created = this.mapGroup(pickFirst(result));
} catch (cause) {
if (cause instanceof ContactsApiError) throw cause;
throw new ContactsApiError(
CONTACTS_API_CODES.CREATE_FAILED,
"failed to create group",
{ cause: cause as Error }
);
}
const actualPath =
parentPath !== "" ? `${parentPath}/${created.id}` : created.id;
const updated = await this.db.query<GroupRow>(
"UPDATE type::record('contact_group', $p0) SET path = $p1",
[this.toRecordId(created.id), actualPath]
);
return this.mapGroup(pickFirst(updated));
}
public async create(group: Group): Promise<Group> {
try {
const result = await this.db.query<GroupRow>(
"CREATE contact_group CONTENT $p0",
[group]
);
return this.mapGroup(pickFirst(result));
} catch (cause) {
throw new ContactsApiError(
CONTACTS_API_CODES.CREATE_FAILED,
"failed to create group",
{ cause: cause as Error }
);
}
}
public async getById(id: string): Promise<Group | null> {
const recordId = this.toRecordId(id);
const result = await this.db.query<GroupRow>(
"SELECT * FROM type::record('contact_group', $p0)",
[recordId]
);
if (result.length === 0) return null;
return this.mapGroup(result[0]!);
}
public async update(id: string, input: GroupUpdatePatch): Promise<Group> {
try {
const recordId = this.toRecordId(id);
const result = await this.db.query<GroupRow>(
"UPDATE type::record('contact_group', $p0) MERGE $p1",
[recordId, input]
);
return this.mapGroup(pickFirst(result));
} catch (cause) {
if (cause instanceof ContactsApiError) throw cause;
throw new ContactsApiError(
CONTACTS_API_CODES.UPDATE_FAILED,
`failed to update group ${id}`,
{ cause: cause as Error, context: { id } }
);
}
}
public async delete(id: string): Promise<void> {
try {
const recordId = this.toRecordId(id);
await this.db.query("DELETE type::record('contact_group', $p0)", [
recordId,
]);
} catch (cause) {
throw new ContactsApiError(
CONTACTS_API_CODES.DELETE_FAILED,
`failed to delete group ${id}`,
{ cause: cause as Error, context: { id } }
);
}
}
public async list(query: GroupQuery): Promise<GroupListResult> {
const params: unknown[] = [query.tenantId];
let where = "tenantId = $p0";
if (query.parentId !== undefined) {
params.push(query.parentId);
where += ` AND parentId = $p${params.length - 1}`;
}
if (query.search !== undefined) {
params.push(query.search);
const idx = params.length - 1;
where += ` AND (string::contains(name, $p${idx}) OR string::contains(description, $p${idx}))`;
}
const limit = query.limit ?? 50;
const offset = query.offset ?? 0;
params.push(limit);
const limitIdx = params.length - 1;
params.push(offset);
const offsetIdx = params.length - 1;
const countResult = await this.db.query<{ count: number }>(
`SELECT count() AS count FROM contact_group WHERE ${where} GROUP ALL`,
params
);
const total = countResult[0]?.count ?? 0;
const rows = await this.db.query<GroupRow>(
`SELECT * FROM contact_group WHERE ${where} ORDER BY name ASC LIMIT $p${limitIdx} START $p${offsetIdx}`,
params
);
return {
groups: rows.map((r) => this.mapGroup(r)),
total,
};
}
public async getChildren(parentId: string): Promise<Group[]> {
const result = await this.db.query<GroupRow>(
"SELECT * FROM contact_group WHERE parentId = $p0 ORDER BY name ASC",
[parentId]
);
return result.map((r) => this.mapGroup(r));
}
public async getDescendants(path: string): Promise<Group[]> {
const result = await this.db.query<GroupRow>(
"SELECT * FROM contact_group WHERE string::contains(path, $p0) AND path != $p0 ORDER BY depth ASC",
[path]
);
return result.map((r) => this.mapGroup(r));
}
public async getRoots(tenantId: string): Promise<Group[]> {
const result = await this.db.query<GroupRow>(
"SELECT * FROM contact_group WHERE tenantId = $p0 AND parentId = NONE ORDER BY name ASC",
[tenantId]
);
return result.map((r) => this.mapGroup(r));
}
public async findByIds(ids: readonly string[]): Promise<Group[]> {
if (ids.length === 0) return [];
const results = await Promise.all(
ids.map((id) =>
this.db.query<GroupRow>(
"SELECT * FROM type::record('contact_group', $p0)",
[this.toRecordId(id)]
)
)
);
return results.flat().map((r) => this.mapGroup(r));
}
public async moveGroup(
id: string,
newParentId: string | null,
tenantId: string
): Promise<Group> {
const group = await this.getById(id);
if (group === null || group.tenantId !== tenantId) {
throw new ContactsApiError(
CONTACTS_API_CODES.NOT_FOUND,
`group ${id} not found`,
{ context: { id, tenantId } }
);
}
let newPath: string;
let newDepth: number;
if (newParentId !== null) {
const parent = await this.getById(newParentId);
if (parent === null) {
throw new ContactsApiError(
CONTACTS_API_CODES.NOT_FOUND,
`parent group ${newParentId} not found`,
{ context: { newParentId } }
);
}
newPath = `${parent.path}/${group.id}`;
newDepth = parent.depth + 1;
} else {
newPath = group.id;
newDepth = 0;
}
if (this.enforceGroupDepth && newDepth >= this.maxGroupDepth) {
throw new ContactsApiError(
CONTACTS_API_CODES.GROUP_DEPTH_EXCEEDED,
`group depth ${newDepth} exceeds maxGroupDepth ${this.maxGroupDepth}`,
{
context: {
depth: newDepth,
maxGroupDepth: this.maxGroupDepth,
newParentId,
},
}
);
}
return this.update(id, {
parentId: newParentId,
path: newPath,
depth: newDepth,
});
}
private toRecordId(id: string): string {
// type::record('contact_group', $tail) — strip leading
// "contact_group:" prefix so callers can pass either form.
const idx = id.indexOf(":");
return idx === -1 ? id : id.slice(idx + 1);
}
private mapGroup(row: GroupRow): Group {
return {
id: String(row.id),
tenantId: row.tenantId,
name: row.name,
...(row.description !== null && row.description !== undefined
? { description: row.description }
: {}),
parentId: row.parentId,
path: row.path,
depth: row.depth,
metadata: row.metadata,
contactCount: row.contactCount,
totalContactCount: row.totalContactCount,
createdAt:
row.createdAt instanceof Date
? row.createdAt
: new Date(row.createdAt),
updatedAt:
row.updatedAt instanceof Date
? row.updatedAt
: new Date(row.updatedAt),
};
}
}
function pickFirst<T>(rows: readonly T[] | null | undefined): T {
if (!rows || rows.length === 0) {
throw new ContactsApiError(
CONTACTS_API_CODES.CREATE_FAILED,
"SurrealDB returned no row for write operation"
);
}
return rows[0]!;
}

View File

@@ -0,0 +1,9 @@
export { SurrealContactRepository } from "./contact-repository.js";
export {
SurrealGroupRepository,
type SurrealGroupRepositoryOptions,
} from "./group-repository.js";
export {
SurrealMembershipRepository,
type SurrealMembershipRepositoryOptions,
} from "./membership-repository.js";

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,187 @@
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CONTACTS_API_CODES } from "../error-codes/error-codes.js";
import { SurrealMembershipRepository } from "./membership-repository.js";
function fake(
handler: (sql: string, params: readonly unknown[] | undefined) => unknown
): SurrealAdapter {
return {
async query(sql: string, params?: readonly unknown[]) {
return handler(sql, params);
},
} as unknown as SurrealAdapter;
}
const row = {
id: "group_membership:m1",
groupId: "g1",
contactId: "c1",
metadata: {},
addedAt: new Date().toISOString(),
};
describe("SurrealMembershipRepository", () => {
it("add returns mapped membership", { timeout: 5000 }, async () => {
const repo = new SurrealMembershipRepository(fake(() => [row]));
const m = await repo.add({
id: "ignored",
groupId: "g1",
contactId: "c1",
addedAt: new Date(),
});
expect(m.id).toBe("group_membership:m1");
expect(m.addedAt).toBeInstanceOf(Date);
});
it("add raises MEMBERSHIP_EXISTS on duplicate", {
timeout: 5000,
}, async () => {
const repo = new SurrealMembershipRepository(
fake(() => {
throw new Error("violates unique index");
})
);
await expect(
repo.add({
id: "x",
groupId: "g1",
contactId: "c1",
addedAt: new Date(),
})
).rejects.toMatchObject({
code: CONTACTS_API_CODES.MEMBERSHIP_EXISTS,
});
});
it("add wraps non-duplicate errors as CREATE_FAILED", {
timeout: 5000,
}, async () => {
const repo = new SurrealMembershipRepository(
fake(() => {
throw new Error("network down");
})
);
await expect(
repo.add({
id: "x",
groupId: "g1",
contactId: "c1",
addedAt: new Date(),
})
).rejects.toMatchObject({ code: CONTACTS_API_CODES.CREATE_FAILED });
});
it("remove wraps adapter errors as DELETE_FAILED", {
timeout: 5000,
}, async () => {
const repo = new SurrealMembershipRepository(
fake(() => {
throw new Error("boom");
})
);
await expect(repo.remove("g1", "c1")).rejects.toMatchObject({
code: CONTACTS_API_CODES.DELETE_FAILED,
});
});
it("getByGroup / getByContact map rows", { timeout: 5000 }, async () => {
const repo = new SurrealMembershipRepository(fake(() => [row]));
expect((await repo.getByGroup("g1"))[0]?.id).toBe(
"group_membership:m1"
);
expect((await repo.getByContact("c1"))[0]?.id).toBe(
"group_membership:m1"
);
});
it("exists reports true / false from count query", {
timeout: 5000,
}, async () => {
const yes = new SurrealMembershipRepository(fake(() => [{ count: 1 }]));
const no = new SurrealMembershipRepository(fake(() => []));
expect(await yes.exists("g1", "c1")).toBe(true);
expect(await no.exists("g1", "c1")).toBe(false);
});
it("removeAllForContact + removeAllForGroup issue DELETE statements", {
timeout: 5000,
}, async () => {
let lastSql = "";
const repo = new SurrealMembershipRepository(
fake((sql) => {
lastSql = sql;
return [];
})
);
await repo.removeAllForContact("c1");
expect(lastSql).toContain("contactId = $p0");
await repo.removeAllForGroup("g1");
expect(lastSql).toContain("groupId = $p0");
});
it("getContactIdsByGroups short-circuits empty input", {
timeout: 5000,
}, async () => {
let called = false;
const repo = new SurrealMembershipRepository(
fake(() => {
called = true;
return [];
})
);
expect(await repo.getContactIdsByGroups([])).toEqual([]);
expect(called).toBe(false);
});
it("getContactIdsByGroups returns flattened contactIds", {
timeout: 5000,
}, async () => {
const repo = new SurrealMembershipRepository(
fake(() => [{ contactId: "c1" }, { contactId: "c2" }])
);
expect(await repo.getContactIdsByGroups(["g1"])).toEqual(["c1", "c2"]);
});
it("countByGroup defaults to 0", { timeout: 5000 }, async () => {
const repo = new SurrealMembershipRepository(fake(() => []));
expect(await repo.countByGroup("g1")).toBe(0);
});
it("countByGroup returns SurrealDB count", { timeout: 5000 }, async () => {
const repo = new SurrealMembershipRepository(
fake(() => [{ count: 7 }])
);
expect(await repo.countByGroup("g1")).toBe(7);
});
it("generateId yields monotonic ids by default", { timeout: 5000 }, () => {
const repo = new SurrealMembershipRepository(fake(() => []));
expect(repo.generateId()).toBe("membership_1");
expect(repo.generateId()).toBe("membership_2");
});
it("generateId honours injected generator", { timeout: 5000 }, () => {
const repo = new SurrealMembershipRepository(
fake(() => []),
{
idGenerator: () => "m-x",
}
);
expect(repo.generateId()).toBe("m-x");
});
it("add raises CREATE_FAILED when adapter returns no row", {
timeout: 5000,
}, async () => {
const repo = new SurrealMembershipRepository(fake(() => []));
await expect(
repo.add({
id: "x",
groupId: "g1",
contactId: "c1",
addedAt: new Date(),
})
).rejects.toMatchObject({ code: CONTACTS_API_CODES.CREATE_FAILED });
});
});

View File

@@ -0,0 +1,180 @@
/**
* SurrealDB-backed implementation of the genesis `@suaveplan/contacts`
* `MembershipStore` interface. Stores the many-to-many join between
* contacts and groups with a unique `(groupId, contactId)` index that
* raises `MEMBERSHIP_EXISTS` on duplicate insertion.
*/
import type { GroupMembership, MembershipStore } from "@suaveplan/contacts";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../error-codes/error-codes.js";
export interface SurrealMembershipRepositoryOptions {
/** Optional id generator (used by `generateId`). */
readonly idGenerator?: () => string;
}
interface MembershipRow {
readonly id: unknown;
readonly groupId: string;
readonly contactId: string;
readonly metadata: Record<string, unknown> | undefined;
readonly addedAt: string | Date;
}
/** SurrealDB-backed membership repository. */
export class SurrealMembershipRepository implements MembershipStore {
private readonly idGenerator: () => string;
private idCounter = 0;
public constructor(
private readonly db: SurrealAdapter,
options: SurrealMembershipRepositoryOptions = {}
) {
this.idGenerator =
options.idGenerator ?? (() => `membership_${++this.idCounter}`);
}
public generateId(): string {
return this.idGenerator();
}
public async add(membership: GroupMembership): Promise<GroupMembership> {
try {
const result = await this.db.query<MembershipRow>(
`CREATE group_membership CONTENT {
groupId: $p0, contactId: $p1,
metadata: $p2
}`,
[
membership.groupId,
membership.contactId,
membership.metadata ?? {},
]
);
return this.mapMembership(pickFirst(result));
} catch (cause) {
if (cause instanceof ContactsApiError) throw cause;
const msg = String((cause as Error).message ?? "");
if (
msg.includes("unique") ||
msg.includes("UNIQUE") ||
msg.includes("duplicate")
) {
throw new ContactsApiError(
CONTACTS_API_CODES.MEMBERSHIP_EXISTS,
`membership (${membership.groupId}, ${membership.contactId}) already exists`,
{
cause: cause as Error,
context: {
groupId: membership.groupId,
contactId: membership.contactId,
},
}
);
}
throw new ContactsApiError(
CONTACTS_API_CODES.CREATE_FAILED,
"failed to add membership",
{ cause: cause as Error }
);
}
}
public async remove(groupId: string, contactId: string): Promise<void> {
try {
await this.db.query(
"DELETE group_membership WHERE groupId = $p0 AND contactId = $p1",
[groupId, contactId]
);
} catch (cause) {
throw new ContactsApiError(
CONTACTS_API_CODES.DELETE_FAILED,
"failed to remove membership",
{ cause: cause as Error, context: { groupId, contactId } }
);
}
}
public async getByGroup(groupId: string): Promise<GroupMembership[]> {
const result = await this.db.query<MembershipRow>(
"SELECT * FROM group_membership WHERE groupId = $p0 ORDER BY addedAt DESC",
[groupId]
);
return result.map((r) => this.mapMembership(r));
}
public async getByContact(contactId: string): Promise<GroupMembership[]> {
const result = await this.db.query<MembershipRow>(
"SELECT * FROM group_membership WHERE contactId = $p0 ORDER BY addedAt DESC",
[contactId]
);
return result.map((r) => this.mapMembership(r));
}
public async exists(groupId: string, contactId: string): Promise<boolean> {
const result = await this.db.query<{ count: number }>(
`SELECT count() AS count FROM group_membership
WHERE groupId = $p0 AND contactId = $p1 GROUP ALL`,
[groupId, contactId]
);
return (result[0]?.count ?? 0) > 0;
}
public async removeAllForContact(contactId: string): Promise<void> {
await this.db.query("DELETE group_membership WHERE contactId = $p0", [
contactId,
]);
}
public async removeAllForGroup(groupId: string): Promise<void> {
await this.db.query("DELETE group_membership WHERE groupId = $p0", [
groupId,
]);
}
public async getContactIdsByGroups(
groupIds: readonly string[]
): Promise<string[]> {
if (groupIds.length === 0) return [];
const result = await this.db.query<{ contactId: string }>(
"SELECT contactId FROM group_membership WHERE groupId IN $p0",
[groupIds]
);
return result.map((r) => r.contactId);
}
public async countByGroup(groupId: string): Promise<number> {
const result = await this.db.query<{ count: number }>(
"SELECT count() AS count FROM group_membership WHERE groupId = $p0 GROUP ALL",
[groupId]
);
return result[0]?.count ?? 0;
}
private mapMembership(row: MembershipRow): GroupMembership {
return {
id: String(row.id),
groupId: row.groupId,
contactId: row.contactId,
addedAt:
row.addedAt instanceof Date
? row.addedAt
: new Date(row.addedAt),
...(row.metadata !== undefined ? { metadata: row.metadata } : {}),
};
}
}
function pickFirst<T>(rows: readonly T[] | null | undefined): T {
if (!rows || rows.length === 0) {
throw new ContactsApiError(
CONTACTS_API_CODES.CREATE_FAILED,
"SurrealDB returned no row for write operation"
);
}
return rows[0]!;
}

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,489 @@
import "@suaveplan/api-plugin-auth";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../../error-codes/error-codes.js";
import { SurrealContactRepository } from "../../repositories/contact-repository.js";
import { buildContactRoutes } from "./build-routes.js";
import "../../context/context.js";
interface FakeAdapterOpts {
rows?: unknown[];
count?: number;
throwOnQuery?: boolean;
}
function fakeAdapter(opts: FakeAdapterOpts = {}): SurrealAdapter {
return {
async query(sql: string) {
if (opts.throwOnQuery === true) throw new Error("nope");
if (sql.startsWith("SELECT count"))
return [{ count: opts.count ?? 0 }];
return opts.rows ?? [];
},
} as unknown as SurrealAdapter;
}
function makeApp(
repo: SurrealContactRepository,
principal: object | null = { id: "u", tenantId: "t" }
): Hono {
const app = new Hono();
app.use("*", async (c, next) => {
if (principal !== null) c.set("user", principal as never);
c.set("contactRepository", repo);
c.set("eventBus", { emit: () => undefined } as never);
await next();
});
app.onError((err, c) => {
if (err instanceof ContactsApiError) {
const status = ((err.context as { statusCode?: number } | undefined)
?.statusCode ?? 500) as 400 | 401 | 404 | 500;
return c.json(
{ success: false, code: err.code, error: err.message },
status
);
}
return c.json({ success: false, error: String(err) }, 500);
});
const entries = buildContactRoutes({ maxImportBatchSize: 3 });
for (const e of entries) {
const method = e.route.method as "get" | "post" | "patch" | "delete";
app[method](e.route.path, (c) => e.handler(c as never));
}
return app;
}
const contactRow = {
id: "contact:1",
tenantId: "t",
displayName: "Ada",
firstName: null,
lastName: null,
channels: [
{ type: "email", value: "a@b.c", primary: true, verified: false },
],
tags: [],
status: "active",
metadata: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
describe("contacts build-routes", () => {
it("buildContactRoutes returns 7 entries in canonical order", {
timeout: 5000,
}, () => {
const entries = buildContactRoutes({ maxImportBatchSize: 1000 });
expect(entries).toHaveLength(7);
expect(entries.map((e) => `${e.route.method} ${e.route.path}`)).toEqual(
[
"get /contacts",
"post /contacts",
"get /contacts/:id",
"patch /contacts/:id",
"delete /contacts/:id",
"post /contacts/import",
"post /contacts/merge",
]
);
});
it("POST /contacts rejects requests without a tenantId", {
timeout: 5000,
}, async () => {
const repo = new SurrealContactRepository(fakeAdapter());
const app = makeApp(repo, null);
const res = await app.request("/contacts", {
method: "POST",
body: JSON.stringify({
displayName: "x",
channels: [{ type: "email", value: "a@b.c" }],
}),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(401);
expect((await res.json()).code).toBe(CONTACTS_API_CODES.UNAUTHORIZED);
});
it("POST /contacts returns 201 on a valid body", {
timeout: 5000,
}, async () => {
const repo = new SurrealContactRepository(
fakeAdapter({ rows: [contactRow] })
);
const app = makeApp(repo);
const res = await app.request("/contacts", {
method: "POST",
body: JSON.stringify({
displayName: "Ada",
channels: [{ type: "email", value: "a@b.c" }],
}),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.data.id).toBe("contact:1");
});
it("POST /contacts returns 400 on invalid body", {
timeout: 5000,
}, async () => {
const repo = new SurrealContactRepository(fakeAdapter());
const app = makeApp(repo);
const res = await app.request("/contacts", {
method: "POST",
body: JSON.stringify({ displayName: "" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
expect((await res.json()).code).toBe(CONTACTS_API_CODES.INVALID_INPUT);
});
it("GET /contacts paginates", { timeout: 5000 }, async () => {
const repo = new SurrealContactRepository(
fakeAdapter({ rows: [contactRow], count: 1 })
);
const app = makeApp(repo);
const res = await app.request("/contacts?limit=10&offset=0&tags=a,b");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.total).toBe(1);
expect(body.data.contacts).toHaveLength(1);
});
it("GET /contacts/:id returns 404 for missing", {
timeout: 5000,
}, async () => {
const repo = new SurrealContactRepository(fakeAdapter({ rows: [] }));
const app = makeApp(repo);
const res = await app.request("/contacts/missing");
expect(res.status).toBe(404);
expect((await res.json()).code).toBe(CONTACTS_API_CODES.NOT_FOUND);
});
it("GET /contacts/:id returns 404 for wrong tenant", {
timeout: 5000,
}, async () => {
const repo = new SurrealContactRepository(
fakeAdapter({ rows: [{ ...contactRow, tenantId: "other" }] })
);
const app = makeApp(repo);
const res = await app.request("/contacts/contact:1");
expect(res.status).toBe(404);
});
it("GET /contacts/:id returns 200 on hit", { timeout: 5000 }, async () => {
const repo = new SurrealContactRepository(
fakeAdapter({ rows: [contactRow] })
);
const app = makeApp(repo);
const res = await app.request("/contacts/contact:1");
expect(res.status).toBe(200);
expect((await res.json()).data.id).toBe("contact:1");
});
it("PATCH /contacts/:id updates a contact", { timeout: 5000 }, async () => {
const repo = new SurrealContactRepository(
fakeAdapter({ rows: [contactRow] })
);
const app = makeApp(repo);
const res = await app.request("/contacts/contact:1", {
method: "PATCH",
body: JSON.stringify({ displayName: "Updated" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(200);
});
it("PATCH /contacts/:id rejects invalid body", {
timeout: 5000,
}, async () => {
const repo = new SurrealContactRepository(
fakeAdapter({ rows: [contactRow] })
);
const app = makeApp(repo);
const res = await app.request("/contacts/contact:1", {
method: "PATCH",
body: JSON.stringify({ displayName: "" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
});
it("PATCH /contacts/:id returns 404 when missing", {
timeout: 5000,
}, async () => {
const repo = new SurrealContactRepository(fakeAdapter({ rows: [] }));
const app = makeApp(repo);
const res = await app.request("/contacts/contact:nope", {
method: "PATCH",
body: JSON.stringify({ displayName: "y" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(404);
});
it("DELETE /contacts/:id returns 204 on hit", {
timeout: 5000,
}, async () => {
const repo = new SurrealContactRepository(
fakeAdapter({ rows: [contactRow] })
);
const app = makeApp(repo);
const res = await app.request("/contacts/contact:1", {
method: "DELETE",
});
expect(res.status).toBe(204);
});
it("DELETE /contacts/:id returns 404 when missing", {
timeout: 5000,
}, async () => {
const repo = new SurrealContactRepository(fakeAdapter({ rows: [] }));
const app = makeApp(repo);
const res = await app.request("/contacts/contact:nope", {
method: "DELETE",
});
expect(res.status).toBe(404);
});
it("POST /contacts/import rejects oversized batches at the schema boundary", {
timeout: 5000,
}, async () => {
const repo = new SurrealContactRepository(
fakeAdapter({ rows: [contactRow] })
);
const app = makeApp(repo);
const contacts = Array.from({ length: 4 }, (_, i) => ({
displayName: `n${i}`,
channels: [{ type: "email", value: `e${i}@x.y` }],
}));
const res = await app.request("/contacts/import", {
method: "POST",
body: JSON.stringify({ contacts }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
expect((await res.json()).code).toBe(
CONTACTS_API_CODES.IMPORT_TOO_LARGE
);
});
it("POST /contacts/import returns per-row errors", {
timeout: 5000,
}, async () => {
let calls = 0;
const adapter = {
async query(sql: string) {
calls++;
if (sql.startsWith("CREATE") && calls === 2) {
throw new Error("constraint violation");
}
if (sql.startsWith("SELECT count")) return [{ count: 0 }];
return [contactRow];
},
} as unknown as SurrealAdapter;
const repo = new SurrealContactRepository(adapter);
const app = makeApp(repo);
const res = await app.request("/contacts/import", {
method: "POST",
body: JSON.stringify({
contacts: [
{
displayName: "a",
channels: [{ type: "email", value: "a@x.y" }],
},
{
displayName: "b",
channels: [{ type: "email", value: "b@x.y" }],
},
],
}),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.imported).toBe(1);
expect(body.data.errors).toHaveLength(1);
expect(body.data.errors[0].index).toBe(1);
});
it("POST /contacts/import respects skipDuplicates", {
timeout: 5000,
}, async () => {
let nthCall = 0;
const adapter = {
async query(sql: string) {
nthCall++;
if (sql.startsWith("SELECT * FROM contact")) {
// findByChannelValue — return a hit so row is skipped.
return [contactRow];
}
if (sql.startsWith("SELECT count")) return [{ count: 0 }];
return [contactRow];
},
} as unknown as SurrealAdapter;
void nthCall;
const repo = new SurrealContactRepository(adapter);
const app = makeApp(repo);
const res = await app.request("/contacts/import", {
method: "POST",
body: JSON.stringify({
contacts: [
{
displayName: "a",
channels: [{ type: "email", value: "a@x.y" }],
},
],
skipDuplicates: true,
}),
headers: { "content-type": "application/json" },
});
const body = await res.json();
expect(body.data.skipped).toBe(1);
expect(body.data.imported).toBe(0);
});
it("POST /contacts/merge merges channel+tag union and removes secondaries", {
timeout: 5000,
}, async () => {
const primary = {
...contactRow,
id: "contact:p",
channels: [
{
type: "email",
value: "a@b.c",
primary: true,
verified: false,
},
],
tags: ["x"],
};
const secondary = {
...contactRow,
id: "contact:s",
channels: [
{ type: "sms", value: "+1", primary: true, verified: false },
],
tags: ["y"],
};
let lastUpdateData: unknown;
const adapter = {
async query(sql: string, params?: readonly unknown[]) {
if (sql.includes("SELECT * FROM type::record")) {
// primary fetch uses contact:p; secondaries use findByIds (one call per id).
if (params?.[0] === "s") return [secondary];
return [primary];
}
if (sql.startsWith("UPDATE")) {
lastUpdateData = params?.[1];
return [
{
...primary,
channels: [
...primary.channels,
{
type: "sms",
value: "+1",
primary: false,
verified: false,
},
],
tags: ["x", "y"],
},
];
}
if (sql.startsWith("DELETE")) return [];
return [];
},
} as unknown as SurrealAdapter;
const repo = new SurrealContactRepository(adapter);
const app = makeApp(repo);
const res = await app.request("/contacts/merge", {
method: "POST",
body: JSON.stringify({
primaryContactId: "contact:p",
mergeContactIds: ["contact:s"],
}),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.removedContactIds).toEqual(["contact:s"]);
expect(lastUpdateData).toBeDefined();
});
it("POST /contacts/merge returns 404 when primary missing", {
timeout: 5000,
}, async () => {
const adapter = {
async query() {
return [];
},
} as unknown as SurrealAdapter;
const repo = new SurrealContactRepository(adapter);
const app = makeApp(repo);
const res = await app.request("/contacts/merge", {
method: "POST",
body: JSON.stringify({
primaryContactId: "contact:nope",
mergeContactIds: ["contact:s"],
}),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(404);
});
it("POST /contacts/merge rejects invalid body", {
timeout: 5000,
}, async () => {
const repo = new SurrealContactRepository(fakeAdapter());
const app = makeApp(repo);
const res = await app.request("/contacts/merge", {
method: "POST",
body: JSON.stringify({}),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
});
it("returns 500 when contactRepository slot is empty", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: "t" } as never);
await next();
});
app.onError((err, c) => {
const code = (err as { code?: string }).code;
return c.json({ code }, 500);
});
const entries = buildContactRoutes({ maxImportBatchSize: 100 });
for (const e of entries) {
const method = e.route.method as
| "get"
| "post"
| "patch"
| "delete";
app[method](e.route.path, (c) => e.handler(c as never));
}
const res = await app.request("/contacts", {
method: "POST",
body: JSON.stringify({
displayName: "a",
channels: [{ type: "email", value: "a@b.c" }],
}),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(500);
expect((await res.json()).code).toBe(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE
);
});
});

View File

@@ -0,0 +1,39 @@
/**
* Aggregator that returns the 7 `/contacts` `PluginRouteEntry` values
* to be mounted by the plugin via `definePluginRoutes`.
*/
import type { PluginRouteEntry } from "@suaveplan/api-openapi";
import {
buildImportContactsRoute,
buildMergeContactsRoute,
} from "./contacts-bulk-routes.js";
import {
buildCreateContactRoute,
buildDeleteContactRoute,
buildGetContactRoute,
buildListContactsRoute,
buildUpdateContactRoute,
} from "./contacts-crud-routes.js";
export interface BuildContactRoutesOptions {
readonly maxImportBatchSize: number;
}
/**
* Returns the 7 `/contacts` plugin-route entries in canonical order:
* list, create, get, update, delete, import, merge.
*/
export function buildContactRoutes(
options: BuildContactRoutesOptions
): readonly PluginRouteEntry[] {
return [
buildListContactsRoute(),
buildCreateContactRoute(),
buildGetContactRoute(),
buildUpdateContactRoute(),
buildDeleteContactRoute(),
buildImportContactsRoute(options.maxImportBatchSize),
buildMergeContactsRoute(),
];
}

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,199 @@
/**
* Bulk-operation route builders for contacts — `POST /contacts/import`
* and `POST /contacts/merge`. Both routes share the same Hono +
* principal + repository wiring as the CRUD builders but participate in
* the two histogram instruments (`import_duration_ms`,
* `merge_duration_ms`).
*/
import {
type PluginRouteEntry,
POST,
successResponse,
} from "@suaveplan/api-openapi";
import type { CreateContactInput } from "@suaveplan/contacts";
import type { Context } from "hono";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../../error-codes/error-codes.js";
import { getContactsApiInstruments } from "../../otel/instruments.js";
import {
BulkImportResponseSchema,
buildBulkImportSchema,
MergeContactsSchema,
MergeResponseSchema,
} from "../../schemas/contacts-schemas.js";
import { requireTenant } from "../shared/require-tenant.js";
import { stripUndefined } from "../shared/strip-undefined.js";
/** `POST /contacts/import` — Zod-bounded bulk import with per-row errors. */
export function buildImportContactsRoute(
maxImportBatchSize: number
): PluginRouteEntry {
const bodySchema = buildBulkImportSchema(maxImportBatchSize);
const route = POST("/contacts/import")
.tags("contacts")
.summary("Bulk import contacts")
.body(bodySchema)
.response(successResponse(BulkImportResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = c.get("contactRepository");
if (repo === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"contactRepository slot is empty"
);
}
const raw = (await c.req.json().catch(() => ({}))) as unknown;
const parsed = bodySchema.safeParse(raw);
if (!parsed.success) {
const tooLarge = parsed.error.issues.some(
(i) => i.code === "too_big"
);
getContactsApiInstruments().importDurationMs.record(0, {
outcome: "error",
});
throw new ContactsApiError(
tooLarge
? CONTACTS_API_CODES.IMPORT_TOO_LARGE
: CONTACTS_API_CODES.INVALID_INPUT,
"POST /contacts/import body failed validation",
{ cause: parsed.error, context: { statusCode: 400 } }
);
}
const { contacts, skipDuplicates } = parsed.data;
const start = Date.now();
let imported = 0;
let skipped = 0;
const errors: Array<{ index: number; error: string }> = [];
let i = -1;
for (const row of contacts) {
i++;
try {
if (skipDuplicates && row.channels.length > 0) {
const ch = row.channels[0]!;
const existing = await repo.findByChannelValue(
tenantId,
ch.type,
ch.value
);
if (existing !== null) {
skipped++;
continue;
}
}
await repo.create({
...(stripUndefined(row) as Omit<
CreateContactInput,
"tenantId"
>),
tenantId,
});
imported++;
} catch (err) {
errors.push({
index: i,
error: err instanceof Error ? err.message : String(err),
});
}
}
getContactsApiInstruments().importDurationMs.record(
Date.now() - start,
{ outcome: "success" }
);
return c.json(
{
success: true as const,
data: { imported, skipped, errors },
},
200
);
}
return { route, handler };
}
/** `POST /contacts/merge` — channel + tag union into the primary. */
export function buildMergeContactsRoute(): PluginRouteEntry {
const route = POST("/contacts/merge")
.tags("contacts")
.summary("Merge duplicate contacts")
.body(MergeContactsSchema)
.response(successResponse(MergeResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = c.get("contactRepository");
if (repo === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"contactRepository slot is empty"
);
}
const raw = (await c.req.json().catch(() => ({}))) as unknown;
const parsed = MergeContactsSchema.safeParse(raw);
if (!parsed.success) {
throw new ContactsApiError(
CONTACTS_API_CODES.INVALID_INPUT,
"POST /contacts/merge body failed validation",
{ cause: parsed.error, context: { statusCode: 400 } }
);
}
const { primaryContactId, mergeContactIds } = parsed.data;
const start = Date.now();
const primary = await repo.getById(primaryContactId);
if (primary === null || primary.tenantId !== tenantId) {
throw new ContactsApiError(
CONTACTS_API_CODES.NOT_FOUND,
`primary contact ${primaryContactId} not found`,
{ context: { statusCode: 404, primaryContactId } }
);
}
const toMerge = await repo.findByIds(mergeContactIds);
const validToMerge = toMerge.filter((ct) => ct.tenantId === tenantId);
const allChannels = [...primary.channels];
const allTags = new Set(primary.tags);
for (const ct of validToMerge) {
for (const ch of ct.channels) {
if (
!allChannels.some(
(e) => e.type === ch.type && e.value === ch.value
)
) {
allChannels.push({ ...ch, primary: false });
}
}
for (const tag of ct.tags) allTags.add(tag);
}
const merged = await repo.update(primaryContactId, {
channels: allChannels,
tags: Array.from(allTags),
});
const removedContactIds = validToMerge.map((ct) => ct.id);
for (const id of removedContactIds) {
await repo.delete(id);
}
getContactsApiInstruments().mergeDurationMs.record(Date.now() - start, {
outcome: "success",
});
return c.json(
{
success: true as const,
data: {
mergedContact: {
...merged,
createdAt: merged.createdAt.toISOString(),
updatedAt: merged.updatedAt.toISOString(),
},
removedContactIds,
},
},
200
);
}
return { route, handler };
}

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,299 @@
/**
* Contact CRUD route builders — list, create, get, update, delete.
* Each builder returns a `PluginRouteEntry` for mounting via
* `definePluginRoutes`. Handlers read the repository from
* `c.get("contactRepository")` (typed via the Hono ContextVariableMap
* augmentation) and the principal via `requireTenant`.
*/
import {
DELETE,
GET,
PATCH,
type PluginRouteEntry,
POST,
successResponse,
} from "@suaveplan/api-openapi";
import type {
CreateContactInput,
UpdateContactInput,
} from "@suaveplan/contacts";
import type { Context } from "hono";
import z from "zod";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../../error-codes/error-codes.js";
import { getContactsApiInstruments } from "../../otel/instruments.js";
import {
ContactListResponseSchema,
ContactQuerySchema,
ContactResponseSchema,
CreateContactSchema,
UpdateContactSchema,
} from "../../schemas/contacts-schemas.js";
import { requireTenant } from "../shared/require-tenant.js";
import { stripUndefined } from "../shared/strip-undefined.js";
const idParam = z.object({ id: z.string().min(1) });
/** `GET /contacts` — paginated, filterable list. */
export function buildListContactsRoute(): PluginRouteEntry {
const route = GET("/contacts")
.tags("contacts")
.summary("List contacts")
.query(ContactQuerySchema)
.response(successResponse(ContactListResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = c.get("contactRepository");
if (repo === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"contactRepository slot is empty"
);
}
const query = ContactQuerySchema.parse({
search: c.req.query("search"),
status: c.req.query("status"),
tags: c.req.query("tags"),
channelType: c.req.query("channelType"),
limit: c.req.query("limit") ?? 50,
offset: c.req.query("offset") ?? 0,
});
const tags =
query.tags !== undefined ? query.tags.split(",") : undefined;
const result = await repo.list({
tenantId,
...(query.search !== undefined ? { search: query.search } : {}),
...(query.status !== undefined ? { status: query.status } : {}),
...(tags !== undefined ? { tags } : {}),
...(query.channelType !== undefined
? { channelType: query.channelType }
: {}),
limit: query.limit,
offset: query.offset,
});
getContactsApiInstruments().contactReadsTotal.add(1);
return c.json(
{
success: true as const,
data: {
contacts: result.contacts.map((ct) => ({
...ct,
createdAt: ct.createdAt.toISOString(),
updatedAt: ct.updatedAt.toISOString(),
})),
total: result.total,
},
},
200
);
}
return { route, handler };
}
/** `POST /contacts` — create. */
export function buildCreateContactRoute(): PluginRouteEntry {
const route = POST("/contacts")
.tags("contacts")
.summary("Create a contact")
.body(CreateContactSchema)
.response(successResponse(ContactResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = c.get("contactRepository");
if (repo === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"contactRepository slot is empty"
);
}
const raw = (await c.req.json().catch(() => ({}))) as unknown;
const parsed = CreateContactSchema.safeParse(raw);
if (!parsed.success) {
getContactsApiInstruments().contactWritesTotal.add(1, {
outcome: "error",
});
throw new ContactsApiError(
CONTACTS_API_CODES.INVALID_INPUT,
"POST /contacts body failed validation",
{ cause: parsed.error, context: { statusCode: 400 } }
);
}
try {
const contact = await repo.create({
...(stripUndefined(parsed.data) as Omit<
CreateContactInput,
"tenantId"
>),
tenantId,
});
const eventBus = c.get("eventBus");
eventBus?.emit?.("contact:created", {
contactId: contact.id,
tenantId,
});
getContactsApiInstruments().contactWritesTotal.add(1, {
outcome: "success",
});
return c.json(
{
success: true as const,
data: {
...contact,
createdAt: contact.createdAt.toISOString(),
updatedAt: contact.updatedAt.toISOString(),
},
},
201
);
} catch (err) {
getContactsApiInstruments().contactWritesTotal.add(1, {
outcome: "error",
});
throw err;
}
}
return { route, handler };
}
/** `GET /contacts/:id` — read. */
export function buildGetContactRoute(): PluginRouteEntry {
const route = GET("/contacts/:id")
.tags("contacts")
.summary("Get contact by id")
.params(idParam)
.response(successResponse(ContactResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = c.get("contactRepository");
if (repo === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"contactRepository slot is empty"
);
}
const id = c.req.param("id");
const contact = await repo.getById(id);
if (contact === null || contact.tenantId !== tenantId) {
throw new ContactsApiError(
CONTACTS_API_CODES.NOT_FOUND,
`contact ${id} not found`,
{ context: { statusCode: 404, id } }
);
}
getContactsApiInstruments().contactReadsTotal.add(1);
return c.json(
{
success: true as const,
data: {
...contact,
createdAt: contact.createdAt.toISOString(),
updatedAt: contact.updatedAt.toISOString(),
},
},
200
);
}
return { route, handler };
}
/** `PATCH /contacts/:id` — update. */
export function buildUpdateContactRoute(): PluginRouteEntry {
const route = PATCH("/contacts/:id")
.tags("contacts")
.summary("Update a contact")
.params(idParam)
.body(UpdateContactSchema)
.response(successResponse(ContactResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = c.get("contactRepository");
if (repo === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"contactRepository slot is empty"
);
}
const id = c.req.param("id");
const raw = (await c.req.json().catch(() => ({}))) as unknown;
const parsed = UpdateContactSchema.safeParse(raw);
if (!parsed.success) {
getContactsApiInstruments().contactWritesTotal.add(1, {
outcome: "error",
});
throw new ContactsApiError(
CONTACTS_API_CODES.INVALID_INPUT,
"PATCH /contacts/:id body failed validation",
{ cause: parsed.error, context: { statusCode: 400 } }
);
}
const existing = await repo.getById(id);
if (existing === null || existing.tenantId !== tenantId) {
throw new ContactsApiError(
CONTACTS_API_CODES.NOT_FOUND,
`contact ${id} not found`,
{ context: { statusCode: 404, id } }
);
}
const updated = await repo.update(
id,
stripUndefined(parsed.data) as UpdateContactInput
);
getContactsApiInstruments().contactWritesTotal.add(1, {
outcome: "success",
});
return c.json(
{
success: true as const,
data: {
...updated,
createdAt: updated.createdAt.toISOString(),
updatedAt: updated.updatedAt.toISOString(),
},
},
200
);
}
return { route, handler };
}
/** `DELETE /contacts/:id` — delete. */
export function buildDeleteContactRoute(): PluginRouteEntry {
const route = DELETE("/contacts/:id")
.tags("contacts")
.summary("Delete a contact")
.params(idParam)
.response(z.object({ success: z.literal(true) }))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = c.get("contactRepository");
if (repo === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"contactRepository slot is empty"
);
}
const id = c.req.param("id");
const existing = await repo.getById(id);
if (existing === null || existing.tenantId !== tenantId) {
throw new ContactsApiError(
CONTACTS_API_CODES.NOT_FOUND,
`contact ${id} not found`,
{ context: { statusCode: 404, id } }
);
}
await repo.delete(id);
getContactsApiInstruments().contactWritesTotal.add(1, {
outcome: "success",
});
return c.body(null, 204);
}
return { route, handler };
}

View File

@@ -0,0 +1,15 @@
export {
type BuildContactRoutesOptions,
buildContactRoutes,
} from "./build-routes.js";
export {
buildImportContactsRoute,
buildMergeContactsRoute,
} from "./contacts-bulk-routes.js";
export {
buildCreateContactRoute,
buildDeleteContactRoute,
buildGetContactRoute,
buildListContactsRoute,
buildUpdateContactRoute,
} from "./contacts-crud-routes.js";

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,443 @@
import "@suaveplan/api-plugin-auth";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../../error-codes/error-codes.js";
import { SurrealContactRepository } from "../../repositories/contact-repository.js";
import { SurrealGroupRepository } from "../../repositories/group-repository.js";
import { SurrealMembershipRepository } from "../../repositories/membership-repository.js";
import { buildGroupRoutes } from "./build-routes.js";
import "../../context/context.js";
const isoDate = new Date().toISOString();
const groupRow = {
id: "contact_group:g1",
tenantId: "t",
name: "Engineering",
description: null,
parentId: null,
path: "g1",
depth: 0,
metadata: {},
contactCount: 0,
totalContactCount: 0,
createdAt: isoDate,
updatedAt: isoDate,
};
const contactRow = {
id: "contact:1",
tenantId: "t",
displayName: "Ada",
firstName: null,
lastName: null,
channels: [
{ type: "email", value: "a@b.c", primary: true, verified: false },
],
tags: [],
status: "active",
metadata: {},
createdAt: isoDate,
updatedAt: isoDate,
};
type Handler = (sql: string, params: readonly unknown[] | undefined) => unknown;
function adapter(handler: Handler): SurrealAdapter {
return {
async query(sql: string, params?: readonly unknown[]) {
return handler(sql, params);
},
} as unknown as SurrealAdapter;
}
function makeApp(
handler: Handler = () => [groupRow],
principal: object | null = { id: "u", tenantId: "t" },
options?: { enforceGroupDepth?: boolean; maxGroupDepth?: number }
): Hono {
const a = adapter(handler);
const groupRepo = new SurrealGroupRepository(a, options);
const memberRepo = new SurrealMembershipRepository(a);
const contactRepo = new SurrealContactRepository(a);
const app = new Hono();
app.use("*", async (c, next) => {
if (principal !== null) c.set("user", principal as never);
c.set("groupRepository", groupRepo);
c.set("membershipRepository", memberRepo);
c.set("contactRepository", contactRepo);
await next();
});
app.onError((err, c) => {
if (err instanceof ContactsApiError) {
const status = ((err.context as { statusCode?: number } | undefined)
?.statusCode ?? 500) as 400 | 401 | 404 | 500;
return c.json(
{ success: false, code: err.code, error: err.message },
status
);
}
return c.json({ success: false, error: String(err) }, 500);
});
for (const e of buildGroupRoutes()) {
const method = e.route.method as "get" | "post" | "patch" | "delete";
app[method](e.route.path, (c) => e.handler(c as never));
}
return app;
}
describe("groups build-routes", () => {
it("buildGroupRoutes returns 11 entries", { timeout: 5000 }, () => {
const entries = buildGroupRoutes();
expect(entries).toHaveLength(11);
});
it("rejects unauthenticated requests", { timeout: 5000 }, async () => {
const app = makeApp(() => [groupRow], null);
const res = await app.request("/groups");
expect(res.status).toBe(401);
});
it("GET /groups returns mapped list", { timeout: 5000 }, async () => {
const app = makeApp((sql) => {
if (sql.startsWith("SELECT count")) return [{ count: 1 }];
return [groupRow];
});
const res = await app.request("/groups");
expect(res.status).toBe(200);
expect((await res.json()).data.total).toBe(1);
});
it("POST /groups creates a root group", { timeout: 5000 }, async () => {
const app = makeApp(() => [groupRow]);
const res = await app.request("/groups", {
method: "POST",
body: JSON.stringify({ name: "Engineering" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(201);
});
it("POST /groups rejects invalid body", { timeout: 5000 }, async () => {
const app = makeApp(() => [groupRow]);
const res = await app.request("/groups", {
method: "POST",
body: JSON.stringify({}),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
});
it("GET /groups/:id returns 404 for missing", {
timeout: 5000,
}, async () => {
const app = makeApp(() => []);
const res = await app.request("/groups/g:nope");
expect(res.status).toBe(404);
});
it("GET /groups/:id returns 200 on hit", { timeout: 5000 }, async () => {
const app = makeApp(() => [groupRow]);
const res = await app.request("/groups/contact_group:g1");
expect(res.status).toBe(200);
});
it("PATCH /groups/:id rejects invalid body", {
timeout: 5000,
}, async () => {
const app = makeApp(() => [groupRow]);
const res = await app.request("/groups/contact_group:g1", {
method: "PATCH",
body: JSON.stringify({ name: "" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
});
it("PATCH /groups/:id returns 404 when missing", {
timeout: 5000,
}, async () => {
const app = makeApp(() => []);
const res = await app.request("/groups/contact_group:nope", {
method: "PATCH",
body: JSON.stringify({ name: "x" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(404);
});
it("PATCH /groups/:id updates", { timeout: 5000 }, async () => {
const app = makeApp(() => [groupRow]);
const res = await app.request("/groups/contact_group:g1", {
method: "PATCH",
body: JSON.stringify({ name: "Renamed" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(200);
});
it("DELETE /groups/:id returns 204", { timeout: 5000 }, async () => {
const app = makeApp(() => [groupRow]);
const res = await app.request("/groups/contact_group:g1", {
method: "DELETE",
});
expect(res.status).toBe(204);
});
it("DELETE /groups/:id returns 404 when missing", {
timeout: 5000,
}, async () => {
const app = makeApp(() => []);
const res = await app.request("/groups/contact_group:nope", {
method: "DELETE",
});
expect(res.status).toBe(404);
});
it("GET /groups/:id/tree returns a typed GroupTreeNode", {
timeout: 5000,
}, async () => {
const child = {
...groupRow,
id: "contact_group:c1",
parentId: "contact_group:g1",
path: "g1/c1",
depth: 1,
};
let call = 0;
const app = makeApp((sql) => {
call++;
if (sql.includes("SELECT * FROM type::record")) return [groupRow];
if (sql.includes("contact_group WHERE string::contains"))
return [child];
return [];
});
void call;
const res = await app.request("/groups/contact_group:g1/tree");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.group.id).toBe("contact_group:g1");
expect(body.data.children).toHaveLength(1);
});
it("GET /groups/:id/tree returns 404 for missing", {
timeout: 5000,
}, async () => {
const app = makeApp(() => []);
const res = await app.request("/groups/contact_group:nope/tree");
expect(res.status).toBe(404);
});
it("POST /groups/:id/move reparents to root", {
timeout: 5000,
}, async () => {
const app = makeApp(() => [groupRow]);
const res = await app.request("/groups/contact_group:g1/move", {
method: "POST",
body: JSON.stringify({ newParentId: null }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(200);
});
it("POST /groups/:id/move rejects invalid body", {
timeout: 5000,
}, async () => {
const app = makeApp(() => [groupRow]);
const res = await app.request("/groups/contact_group:g1/move", {
method: "POST",
body: JSON.stringify({}),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
});
it("POST /groups/:id/move with depth-enforce surfaces GROUP_DEPTH_EXCEEDED", {
timeout: 5000,
}, async () => {
const parent = {
...groupRow,
id: "contact_group:p",
depth: 5,
path: "p",
};
const app = makeApp(
(_sql, params) => {
if (params?.[0] === "p") return [parent];
if (params?.[0] === "g1") return [groupRow];
return [];
},
{ id: "u", tenantId: "t" },
{ maxGroupDepth: 3, enforceGroupDepth: true }
);
const res = await app.request("/groups/contact_group:g1/move", {
method: "POST",
body: JSON.stringify({ newParentId: "contact_group:p" }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(500);
expect((await res.json()).code).toBe(
CONTACTS_API_CODES.GROUP_DEPTH_EXCEEDED
);
});
it("GET /groups/:id/members lists members", { timeout: 5000 }, async () => {
const m = {
id: "group_membership:m1",
groupId: "contact_group:g1",
contactId: "contact:1",
metadata: {},
addedAt: isoDate,
};
const app = makeApp(() => [m]);
const res = await app.request("/groups/contact_group:g1/members");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.total).toBe(1);
});
it("POST /groups/:id/members adds new ids and skips existing", {
timeout: 5000,
}, async () => {
let call = 0;
const app = makeApp((sql) => {
call++;
if (sql.startsWith("SELECT count")) {
// First exists check returns 0 (new), second returns 1 (already).
return [{ count: call > 1 ? 1 : 0 }];
}
if (sql.startsWith("CREATE group_membership"))
return [
[
{
id: "group_membership:x",
groupId: "contact_group:g1",
contactId: "c",
metadata: {},
addedAt: isoDate,
},
],
];
return [];
});
const res = await app.request("/groups/contact_group:g1/members", {
method: "POST",
body: JSON.stringify({ contactIds: ["c1", "c2"] }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.data.added).toBeLessThanOrEqual(2);
});
it("POST /groups/:id/members rejects invalid body", {
timeout: 5000,
}, async () => {
const app = makeApp(() => []);
const res = await app.request("/groups/contact_group:g1/members", {
method: "POST",
body: JSON.stringify({ contactIds: [] }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
});
it("DELETE /groups/:id/members removes existing only", {
timeout: 5000,
}, async () => {
let call = 0;
const app = makeApp((sql) => {
call++;
if (sql.startsWith("SELECT count")) {
return [{ count: call === 1 ? 1 : 0 }];
}
return [];
});
const res = await app.request("/groups/contact_group:g1/members", {
method: "DELETE",
body: JSON.stringify({ contactIds: ["c1", "c2"] }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.removed).toBe(1);
});
it("DELETE /groups/:id/members rejects invalid body", {
timeout: 5000,
}, async () => {
const app = makeApp(() => []);
const res = await app.request("/groups/contact_group:g1/members", {
method: "DELETE",
body: JSON.stringify({ contactIds: [] }),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(400);
});
it("GET /groups/:id/resolve flattens group + descendants → contacts", {
timeout: 5000,
}, async () => {
const child = {
...groupRow,
id: "contact_group:c",
parentId: "contact_group:g1",
path: "g1/c",
depth: 1,
};
const app = makeApp((sql, params) => {
if (sql.includes("SELECT * FROM type::record")) {
if (params?.[0] === "1") return [contactRow];
return [groupRow];
}
if (sql.includes("string::contains(path")) return [child];
if (sql.startsWith("SELECT contactId"))
return [{ contactId: "1" }, { contactId: "1" }];
return [];
});
const res = await app.request("/groups/contact_group:g1/resolve");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.contacts).toHaveLength(1);
expect(body.data.groupsResolved).toBe(2);
});
it("GET /groups/:id/resolve returns 404 when group missing", {
timeout: 5000,
}, async () => {
const app = makeApp(() => []);
const res = await app.request("/groups/contact_group:nope/resolve");
expect(res.status).toBe(404);
});
it("returns 500 when repository slots are empty", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.use("*", async (c, next) => {
(c as unknown as { set: (k: string, v: unknown) => void }).set(
"user",
{ id: "u", tenantId: "t" }
);
await next();
});
app.onError((err, c) =>
c.json({ code: (err as { code?: string }).code }, 500)
);
for (const e of buildGroupRoutes()) {
const method = e.route.method as
| "get"
| "post"
| "patch"
| "delete";
app[method](e.route.path, (c) => e.handler(c as never) as never);
}
const res = await app.request("/groups");
expect(res.status).toBe(500);
expect(((await res.json()) as { code: string }).code).toBe(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE
);
});
});

View File

@@ -0,0 +1,41 @@
/**
* Aggregator that returns the 9 `/groups` `PluginRouteEntry` values.
*/
import type { PluginRouteEntry } from "@suaveplan/api-openapi";
import {
buildCreateGroupRoute,
buildDeleteGroupRoute,
buildGetGroupRoute,
buildListGroupsRoute,
buildUpdateGroupRoute,
} from "./groups-crud-routes.js";
import {
buildAddMembersRoute,
buildListMembersRoute,
buildRemoveMembersRoute,
buildResolveGroupRoute,
} from "./groups-members-routes.js";
import {
buildGroupTreeRoute,
buildMoveGroupRoute,
} from "./groups-tree-routes.js";
/**
* Returns the 9 `/groups` plugin-route entries in canonical order.
*/
export function buildGroupRoutes(): readonly PluginRouteEntry[] {
return [
buildListGroupsRoute(),
buildCreateGroupRoute(),
buildGetGroupRoute(),
buildUpdateGroupRoute(),
buildDeleteGroupRoute(),
buildGroupTreeRoute(),
buildMoveGroupRoute(),
buildListMembersRoute(),
buildAddMembersRoute(),
buildRemoveMembersRoute(),
buildResolveGroupRoute(),
];
}

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,262 @@
/**
* Group CRUD route builders — list, create, get, update, delete.
*/
import {
DELETE,
GET,
PATCH,
type PluginRouteEntry,
POST,
successResponse,
} from "@suaveplan/api-openapi";
import type { CreateGroupInput } from "@suaveplan/contacts";
import type { Context } from "hono";
import z from "zod";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../../error-codes/error-codes.js";
import { getContactsApiInstruments } from "../../otel/instruments.js";
import type { GroupUpdatePatch } from "../../repositories/group-repository.js";
import {
CreateGroupSchema,
GroupListResponseSchema,
GroupQuerySchema,
GroupResponseSchema,
UpdateGroupSchema,
} from "../../schemas/groups-schemas.js";
import { requireTenant } from "../shared/require-tenant.js";
import { stripUndefined } from "../shared/strip-undefined.js";
const idParam = z.object({ id: z.string().min(1) });
function toResponseGroup<T extends { createdAt: Date; updatedAt: Date }>(g: T) {
return {
...g,
createdAt: g.createdAt.toISOString(),
updatedAt: g.updatedAt.toISOString(),
};
}
/** `GET /groups`. */
export function buildListGroupsRoute(): PluginRouteEntry {
const route = GET("/groups")
.tags("groups")
.summary("List groups")
.query(GroupQuerySchema)
.response(successResponse(GroupListResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = c.get("groupRepository");
if (repo === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"groupRepository slot is empty"
);
}
const query = GroupQuerySchema.parse({
search: c.req.query("search"),
parentId: c.req.query("parentId"),
limit: c.req.query("limit") ?? 50,
offset: c.req.query("offset") ?? 0,
});
const result = await repo.list({
tenantId,
...(query.search !== undefined ? { search: query.search } : {}),
...(query.parentId !== undefined
? { parentId: query.parentId }
: {}),
limit: query.limit,
offset: query.offset,
});
return c.json(
{
success: true as const,
data: {
groups: result.groups.map(toResponseGroup),
total: result.total,
},
},
200
);
}
return { route, handler };
}
/** `POST /groups`. */
export function buildCreateGroupRoute(): PluginRouteEntry {
const route = POST("/groups")
.tags("groups")
.summary("Create a group")
.body(CreateGroupSchema)
.response(successResponse(GroupResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = c.get("groupRepository");
if (repo === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"groupRepository slot is empty"
);
}
const raw = (await c.req.json().catch(() => ({}))) as unknown;
const parsed = CreateGroupSchema.safeParse(raw);
if (!parsed.success) {
getContactsApiInstruments().groupWritesTotal.add(1, {
outcome: "error",
});
throw new ContactsApiError(
CONTACTS_API_CODES.INVALID_INPUT,
"POST /groups body failed validation",
{ cause: parsed.error, context: { statusCode: 400 } }
);
}
try {
const group = await repo.createGroup({
...(stripUndefined(parsed.data) as Omit<
CreateGroupInput,
"tenantId"
>),
tenantId,
});
getContactsApiInstruments().groupWritesTotal.add(1, {
outcome: "success",
});
return c.json(
{ success: true as const, data: toResponseGroup(group) },
201
);
} catch (err) {
getContactsApiInstruments().groupWritesTotal.add(1, {
outcome: "error",
});
throw err;
}
}
return { route, handler };
}
/** `GET /groups/:id`. */
export function buildGetGroupRoute(): PluginRouteEntry {
const route = GET("/groups/:id")
.tags("groups")
.summary("Get group by id")
.params(idParam)
.response(successResponse(GroupResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = c.get("groupRepository");
if (repo === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"groupRepository slot is empty"
);
}
const id = c.req.param("id");
const group = await repo.getById(id);
if (group === null || group.tenantId !== tenantId) {
throw new ContactsApiError(
CONTACTS_API_CODES.NOT_FOUND,
`group ${id} not found`,
{ context: { statusCode: 404, id } }
);
}
return c.json(
{ success: true as const, data: toResponseGroup(group) },
200
);
}
return { route, handler };
}
/** `PATCH /groups/:id`. */
export function buildUpdateGroupRoute(): PluginRouteEntry {
const route = PATCH("/groups/:id")
.tags("groups")
.summary("Update a group")
.params(idParam)
.body(UpdateGroupSchema)
.response(successResponse(GroupResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = c.get("groupRepository");
if (repo === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"groupRepository slot is empty"
);
}
const id = c.req.param("id");
const raw = (await c.req.json().catch(() => ({}))) as unknown;
const parsed = UpdateGroupSchema.safeParse(raw);
if (!parsed.success) {
throw new ContactsApiError(
CONTACTS_API_CODES.INVALID_INPUT,
"PATCH /groups/:id body failed validation",
{ cause: parsed.error, context: { statusCode: 400 } }
);
}
const existing = await repo.getById(id);
if (existing === null || existing.tenantId !== tenantId) {
throw new ContactsApiError(
CONTACTS_API_CODES.NOT_FOUND,
`group ${id} not found`,
{ context: { statusCode: 404, id } }
);
}
const updated = await repo.update(
id,
stripUndefined(parsed.data) as GroupUpdatePatch
);
getContactsApiInstruments().groupWritesTotal.add(1, {
outcome: "success",
});
return c.json(
{ success: true as const, data: toResponseGroup(updated) },
200
);
}
return { route, handler };
}
/** `DELETE /groups/:id`. */
export function buildDeleteGroupRoute(): PluginRouteEntry {
const route = DELETE("/groups/:id")
.tags("groups")
.summary("Delete a group")
.params(idParam)
.response(z.object({ success: z.literal(true) }))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = c.get("groupRepository");
const memberRepo = c.get("membershipRepository");
if (repo === undefined || memberRepo === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"groupRepository / membershipRepository slot is empty"
);
}
const id = c.req.param("id");
const existing = await repo.getById(id);
if (existing === null || existing.tenantId !== tenantId) {
throw new ContactsApiError(
CONTACTS_API_CODES.NOT_FOUND,
`group ${id} not found`,
{ context: { statusCode: 404, id } }
);
}
await memberRepo.removeAllForGroup(id);
await repo.delete(id);
getContactsApiInstruments().groupWritesTotal.add(1, {
outcome: "success",
});
return c.body(null, 204);
}
return { route, handler };
}

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,214 @@
/**
* Group membership routes — `GET /groups/:id/members`,
* `POST /groups/:id/members`, `DELETE /groups/:id/members`,
* `GET /groups/:id/resolve`.
*/
import {
DELETE,
GET,
type PluginRouteEntry,
POST,
successResponse,
} from "@suaveplan/api-openapi";
import type { Context } from "hono";
import z from "zod";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../../error-codes/error-codes.js";
import { getContactsApiInstruments } from "../../otel/instruments.js";
import {
AddMembersSchema,
MembersListResponseSchema,
RemoveMembersSchema,
ResolvedAudienceSchema,
} from "../../schemas/groups-schemas.js";
import { requireTenant } from "../shared/require-tenant.js";
const idParam = z.object({ id: z.string().min(1) });
/** `GET /groups/:id/members`. */
export function buildListMembersRoute(): PluginRouteEntry {
const route = GET("/groups/:id/members")
.tags("groups")
.summary("List group members")
.params(idParam)
.response(successResponse(MembersListResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
requireTenant(c);
const memberRepo = c.get("membershipRepository");
if (memberRepo === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"membershipRepository slot is empty"
);
}
const id = c.req.param("id");
const members = await memberRepo.getByGroup(id);
return c.json(
{
success: true as const,
data: {
members: members.map((m) => ({
...m,
addedAt: m.addedAt.toISOString(),
})),
total: members.length,
},
},
200
);
}
return { route, handler };
}
/** `POST /groups/:id/members` — idempotent add. */
export function buildAddMembersRoute(): PluginRouteEntry {
const route = POST("/groups/:id/members")
.tags("groups")
.summary("Add members to a group")
.params(idParam)
.body(AddMembersSchema)
.response(successResponse(z.object({ added: z.number() })))
.build();
async function handler(c: Context): Promise<Response> {
requireTenant(c);
const memberRepo = c.get("membershipRepository");
if (memberRepo === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"membershipRepository slot is empty"
);
}
const id = c.req.param("id");
const raw = (await c.req.json().catch(() => ({}))) as unknown;
const parsed = AddMembersSchema.safeParse(raw);
if (!parsed.success) {
throw new ContactsApiError(
CONTACTS_API_CODES.INVALID_INPUT,
"POST /groups/:id/members body failed validation",
{ cause: parsed.error, context: { statusCode: 400 } }
);
}
let added = 0;
for (const contactId of parsed.data.contactIds) {
const exists = await memberRepo.exists(id, contactId);
if (!exists) {
await memberRepo.add({
id: memberRepo.generateId(),
groupId: id,
contactId,
addedAt: new Date(),
});
added++;
}
}
getContactsApiInstruments().membershipWritesTotal.add(added, {
outcome: "success",
});
return c.json({ success: true as const, data: { added } }, 201);
}
return { route, handler };
}
/** `DELETE /groups/:id/members`. */
export function buildRemoveMembersRoute(): PluginRouteEntry {
const route = DELETE("/groups/:id/members")
.tags("groups")
.summary("Remove members from a group")
.params(idParam)
.body(RemoveMembersSchema)
.response(successResponse(z.object({ removed: z.number() })))
.build();
async function handler(c: Context): Promise<Response> {
requireTenant(c);
const memberRepo = c.get("membershipRepository");
if (memberRepo === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"membershipRepository slot is empty"
);
}
const id = c.req.param("id");
const raw = (await c.req.json().catch(() => ({}))) as unknown;
const parsed = RemoveMembersSchema.safeParse(raw);
if (!parsed.success) {
throw new ContactsApiError(
CONTACTS_API_CODES.INVALID_INPUT,
"DELETE /groups/:id/members body failed validation",
{ cause: parsed.error, context: { statusCode: 400 } }
);
}
let removed = 0;
for (const contactId of parsed.data.contactIds) {
const exists = await memberRepo.exists(id, contactId);
if (exists) {
await memberRepo.remove(id, contactId);
removed++;
}
}
getContactsApiInstruments().membershipWritesTotal.add(removed, {
outcome: "success",
});
return c.json({ success: true as const, data: { removed } }, 200);
}
return { route, handler };
}
/** `GET /groups/:id/resolve` — flatten group + descendants → contacts. */
export function buildResolveGroupRoute(): PluginRouteEntry {
const route = GET("/groups/:id/resolve")
.tags("groups")
.summary("Resolve a group to a flat contact list")
.params(idParam)
.response(successResponse(ResolvedAudienceSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const groupRepo = c.get("groupRepository");
const memberRepo = c.get("membershipRepository");
const contactRepo = c.get("contactRepository");
if (
groupRepo === undefined ||
memberRepo === undefined ||
contactRepo === undefined
) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"groupRepository / membershipRepository / contactRepository slot is empty"
);
}
const id = c.req.param("id");
const group = await groupRepo.getById(id);
if (group === null || group.tenantId !== tenantId) {
throw new ContactsApiError(
CONTACTS_API_CODES.NOT_FOUND,
`group ${id} not found`,
{ context: { statusCode: 404, id } }
);
}
const descendants = await groupRepo.getDescendants(group.path);
const allGroupIds = [id, ...descendants.map((d) => d.id)];
const contactIds = await memberRepo.getContactIdsByGroups(allGroupIds);
const uniqueIds = Array.from(new Set(contactIds));
const contacts = await contactRepo.findByIds(uniqueIds);
return c.json(
{
success: true as const,
data: {
contacts: contacts.map((ct) => ({
...ct,
createdAt: ct.createdAt.toISOString(),
updatedAt: ct.updatedAt.toISOString(),
})),
totalCount: contacts.length,
groupsResolved: allGroupIds.length,
},
},
200
);
}
return { route, handler };
}

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,120 @@
/**
* Group hierarchy routes — `GET /groups/:id/tree` and
* `POST /groups/:id/move`.
*/
import {
GET,
type PluginRouteEntry,
POST,
successResponse,
} from "@suaveplan/api-openapi";
import type { Context } from "hono";
import z from "zod";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../../error-codes/error-codes.js";
import { getContactsApiInstruments } from "../../otel/instruments.js";
import {
GroupResponseSchema,
GroupTreeNodeSchema,
MoveGroupSchema,
} from "../../schemas/groups-schemas.js";
import { buildTree } from "../../tree/build-tree.js";
import { requireTenant } from "../shared/require-tenant.js";
const idParam = z.object({ id: z.string().min(1) });
function isoize<T extends { createdAt: Date; updatedAt: Date }>(g: T) {
return {
...g,
createdAt: g.createdAt.toISOString(),
updatedAt: g.updatedAt.toISOString(),
};
}
/** `GET /groups/:id/tree` — returns a typed `GroupTreeNode`. */
export function buildGroupTreeRoute(): PluginRouteEntry {
const route = GET("/groups/:id/tree")
.tags("groups")
.summary("Get a group subtree")
.params(idParam)
.response(successResponse(GroupTreeNodeSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = c.get("groupRepository");
if (repo === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"groupRepository slot is empty"
);
}
const id = c.req.param("id");
const group = await repo.getById(id);
if (group === null || group.tenantId !== tenantId) {
throw new ContactsApiError(
CONTACTS_API_CODES.NOT_FOUND,
`group ${id} not found`,
{ context: { statusCode: 404, id } }
);
}
const descendants = await repo.getDescendants(group.path);
const tree = buildTree(group, descendants);
const wrap = (node: typeof tree): unknown => ({
group: isoize(node.group),
children: node.children.map((c2) => wrap(c2)),
});
return c.json({ success: true as const, data: wrap(tree) }, 200);
}
return { route, handler };
}
/** `POST /groups/:id/move` — reparent. */
export function buildMoveGroupRoute(): PluginRouteEntry {
const route = POST("/groups/:id/move")
.tags("groups")
.summary("Move a group to a new parent")
.params(idParam)
.body(MoveGroupSchema)
.response(successResponse(GroupResponseSchema))
.build();
async function handler(c: Context): Promise<Response> {
const { tenantId } = requireTenant(c);
const repo = c.get("groupRepository");
if (repo === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"groupRepository slot is empty"
);
}
const id = c.req.param("id");
const raw = (await c.req.json().catch(() => ({}))) as unknown;
const parsed = MoveGroupSchema.safeParse(raw);
if (!parsed.success) {
throw new ContactsApiError(
CONTACTS_API_CODES.INVALID_INPUT,
"POST /groups/:id/move body failed validation",
{ cause: parsed.error, context: { statusCode: 400 } }
);
}
try {
const moved = await repo.moveGroup(
id,
parsed.data.newParentId,
tenantId
);
getContactsApiInstruments().groupWritesTotal.add(1, {
outcome: "success",
});
return c.json({ success: true as const, data: isoize(moved) }, 200);
} catch (err) {
getContactsApiInstruments().groupWritesTotal.add(1, {
outcome: "error",
});
throw err;
}
}
return { route, handler };
}

View File

@@ -0,0 +1,18 @@
export { buildGroupRoutes } from "./build-routes.js";
export {
buildCreateGroupRoute,
buildDeleteGroupRoute,
buildGetGroupRoute,
buildListGroupsRoute,
buildUpdateGroupRoute,
} from "./groups-crud-routes.js";
export {
buildAddMembersRoute,
buildListMembersRoute,
buildRemoveMembersRoute,
buildResolveGroupRoute,
} from "./groups-members-routes.js";
export {
buildGroupTreeRoute,
buildMoveGroupRoute,
} from "./groups-tree-routes.js";

View File

@@ -0,0 +1,9 @@
export {
type BuildContactRoutesOptions,
buildContactRoutes,
} from "./contacts/build-routes.js";
export { buildGroupRoutes } from "./groups/build-routes.js";
export {
requireTenant,
type TenantPrincipal,
} from "./shared/require-tenant.js";

View File

@@ -0,0 +1,5 @@
export {
requireTenant,
type TenantPrincipal,
} from "./require-tenant.js";
export { stripUndefined } from "./strip-undefined.js";

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,81 @@
import "@suaveplan/api-plugin-auth";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { CONTACTS_API_CODES } from "../../error-codes/error-codes.js";
import { requireTenant } from "./require-tenant.js";
describe("requireTenant", () => {
it("returns id + tenantId when principal is well-formed", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.get("/x", (c) => {
c.set("user", { id: "u1", tenantId: "t1" } as never);
const t = requireTenant(c);
return c.json(t);
});
const res = await app.request("/x");
expect(await res.json()).toEqual({ id: "u1", tenantId: "t1" });
});
it("throws UNAUTHORIZED when principal absent", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.get("/x", (c) => {
return c.json(requireTenant(c));
});
app.onError((err, c) =>
c.json({ code: (err as { code?: string }).code }, 500)
);
const res = await app.request("/x");
const body = (await res.json()) as { code: string };
expect(body.code).toBe(CONTACTS_API_CODES.UNAUTHORIZED);
});
it("throws UNAUTHORIZED when tenantId missing", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.get("/x", (c) => {
c.set("user", { id: "u1" } as never);
return c.json(requireTenant(c));
});
app.onError((err, c) =>
c.json({ code: (err as { code?: string }).code }, 500)
);
const res = await app.request("/x");
expect(((await res.json()) as { code: string }).code).toBe(
CONTACTS_API_CODES.UNAUTHORIZED
);
});
it("throws UNAUTHORIZED when tenantId empty string", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.get("/x", (c) => {
c.set("user", { id: "u1", tenantId: "" });
return c.json(requireTenant(c));
});
app.onError((err, c) =>
c.json({ code: (err as { code?: string }).code }, 500)
);
const res = await app.request("/x");
expect(((await res.json()) as { code: string }).code).toBe(
CONTACTS_API_CODES.UNAUTHORIZED
);
});
it("defaults missing principal.id to empty string", {
timeout: 5000,
}, async () => {
const app = new Hono();
app.get("/x", (c) => {
c.set("user", { tenantId: "t1" } as never);
return c.json(requireTenant(c));
});
const res = await app.request("/x");
expect(await res.json()).toEqual({ id: "", tenantId: "t1" });
});
});

View File

@@ -0,0 +1,50 @@
/**
* Tenant-extraction helper. Reads the `Principal` from `c.get("user")`
* and either returns its non-empty `tenantId` or throws
* `ContactsApiError(UNAUTHORIZED)` mapped to HTTP 401. Routes call
* this once at handler entry so the rest of the handler can treat
* `tenantId` as definitely present.
*/
import type { Context } from "hono";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../../error-codes/error-codes.js";
export interface TenantPrincipal {
readonly id: string;
readonly tenantId: string;
}
/**
* Return `{ id, tenantId }` from the request principal or throw
* `UNAUTHORIZED`. The shared helper eliminates the upstream port's
* repeated five-line `c.get("user")?.tenantId` checks and the
* `as any` casts they relied on.
*/
export function requireTenant(c: Context): TenantPrincipal {
const principal = c.get("user") as
| { id?: unknown; tenantId?: unknown }
| null
| undefined;
if (
principal === null ||
principal === undefined ||
typeof principal.tenantId !== "string" ||
principal.tenantId.length === 0
) {
throw new ContactsApiError(
CONTACTS_API_CODES.UNAUTHORIZED,
"request principal is missing a tenantId",
{ context: { statusCode: 401 } }
);
}
return {
id:
typeof principal.id === "string" && principal.id.length > 0
? principal.id
: "",
tenantId: principal.tenantId,
};
}

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,30 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { stripUndefined } from "./strip-undefined.js";
describe("stripUndefined", () => {
it("removes keys whose values are undefined", { timeout: 5000 }, () => {
const r = stripUndefined({ a: 1, b: undefined, c: "x" });
expect(r).toEqual({ a: 1, c: "x" });
expect("b" in r).toBe(false);
});
it("preserves null and falsy non-undefined values", {
timeout: 5000,
}, () => {
const r = stripUndefined({ a: null, b: 0, c: "", d: false });
expect(r).toEqual({ a: null, b: 0, c: "", d: false });
});
it("returns an empty object when every value is undefined", {
timeout: 5000,
}, () => {
expect(stripUndefined({ a: undefined, b: undefined })).toEqual({});
});
it("is shallow — nested undefineds are preserved", {
timeout: 5000,
}, () => {
const r = stripUndefined({ a: { b: undefined } });
expect(r).toEqual({ a: { b: undefined } });
});
});

View File

@@ -0,0 +1,22 @@
/**
* Strip keys whose values are `undefined`. Used at every route → repo
* boundary to bridge Zod's `optional()` semantics (which produce
* `string | undefined`) with the `exactOptionalPropertyTypes: true`
* Contact / Group / Membership input types in `@suaveplan/contacts`
* (which forbid the `| undefined` half).
*/
/**
* Return a shallow copy of the input with all keys whose values are
* exactly `undefined` removed. Non-`undefined` values are preserved
* as-is; `null` is preserved.
*/
export function stripUndefined<T extends Record<string, unknown>>(
input: T
): { [K in keyof T]: Exclude<T[K], undefined> } {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(input)) {
if (v !== undefined) out[k] = v;
}
return out as { [K in keyof T]: Exclude<T[K], undefined> };
}

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,171 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BulkImportResponseSchema,
buildBulkImportSchema,
ContactChannelSchema,
ContactListResponseSchema,
ContactQuerySchema,
ContactResponseSchema,
ContactStatusSchema,
CreateContactSchema,
MergeContactsSchema,
MergeResponseSchema,
UpdateContactSchema,
} from "./contacts-schemas.js";
describe("contacts-schemas", () => {
it("CreateContactSchema requires at least one channel", {
timeout: 5000,
}, () => {
const ok = CreateContactSchema.safeParse({
displayName: "Ada",
channels: [{ type: "email", value: "a@b.c" }],
});
expect(ok.success).toBe(true);
const bad = CreateContactSchema.safeParse({
displayName: "Ada",
channels: [],
});
expect(bad.success).toBe(false);
});
it("UpdateContactSchema permits sparse updates", { timeout: 5000 }, () => {
const r = UpdateContactSchema.safeParse({ status: "archived" });
expect(r.success).toBe(true);
});
it("ContactQuerySchema coerces numeric strings", { timeout: 5000 }, () => {
const r = ContactQuerySchema.parse({ limit: "10", offset: "5" });
expect(r.limit).toBe(10);
expect(r.offset).toBe(5);
});
it("ContactQuerySchema rejects out-of-range limit", {
timeout: 5000,
}, () => {
const r = ContactQuerySchema.safeParse({ limit: 1000 });
expect(r.success).toBe(false);
});
it("ContactResponseSchema accepts a full response", {
timeout: 5000,
}, () => {
const r = ContactResponseSchema.safeParse({
id: "x",
tenantId: "t",
displayName: "Ada",
channels: [{ type: "email", value: "a@b.c" }],
tags: [],
status: "active",
metadata: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
expect(r.success).toBe(true);
});
it("ContactListResponseSchema validates list shape", {
timeout: 5000,
}, () => {
const r = ContactListResponseSchema.safeParse({
contacts: [],
total: 0,
});
expect(r.success).toBe(true);
});
it("buildBulkImportSchema enforces the supplied cap", {
timeout: 5000,
}, () => {
const schema = buildBulkImportSchema(2);
const ok = schema.safeParse({
contacts: [
{
displayName: "a",
channels: [{ type: "email", value: "a@b.c" }],
},
{
displayName: "b",
channels: [{ type: "email", value: "b@b.c" }],
},
],
});
expect(ok.success).toBe(true);
const bad = schema.safeParse({
contacts: [
{
displayName: "a",
channels: [{ type: "email", value: "a@b.c" }],
},
{
displayName: "b",
channels: [{ type: "email", value: "b@b.c" }],
},
{
displayName: "c",
channels: [{ type: "email", value: "c@b.c" }],
},
],
});
expect(bad.success).toBe(false);
});
it("BulkImportResponseSchema validates per-row errors", {
timeout: 5000,
}, () => {
const r = BulkImportResponseSchema.safeParse({
imported: 0,
skipped: 0,
errors: [{ index: 0, error: "boom" }],
});
expect(r.success).toBe(true);
});
it("MergeContactsSchema rejects empty mergeContactIds", {
timeout: 5000,
}, () => {
const r = MergeContactsSchema.safeParse({
primaryContactId: "x",
mergeContactIds: [],
});
expect(r.success).toBe(false);
});
it("MergeResponseSchema accepts a merged contact + removed ids", {
timeout: 5000,
}, () => {
const r = MergeResponseSchema.safeParse({
mergedContact: {
id: "x",
tenantId: "t",
displayName: "Ada",
channels: [{ type: "email", value: "a@b.c" }],
tags: [],
status: "active",
metadata: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
removedContactIds: ["y"],
});
expect(r.success).toBe(true);
});
it("ContactChannelSchema defaults primary/verified to false", {
timeout: 5000,
}, () => {
const r = ContactChannelSchema.parse({
type: "email",
value: "a@b.c",
});
expect(r.primary).toBe(false);
expect(r.verified).toBe(false);
});
it("ContactStatusSchema rejects unknown statuses", {
timeout: 5000,
}, () => {
const r = ContactStatusSchema.safeParse("invalid");
expect(r.success).toBe(false);
});
});

View File

@@ -0,0 +1,111 @@
import z from "zod";
/** Contact channel (email / sms / phone / imessage / slack / …). */
export const ContactChannelSchema = z.object({
type: z.string().min(1),
value: z.string().min(1),
primary: z.boolean().default(false),
verified: z.boolean().default(false),
metadata: z.record(z.string(), z.unknown()).optional(),
});
/** Contact status enum (matches `@suaveplan/contacts` `ContactStatus`). */
export const ContactStatusSchema = z.enum([
"active",
"unsubscribed",
"bounced",
"complained",
"archived",
]);
/** Schema for `POST /contacts`. */
export const CreateContactSchema = z.object({
displayName: z.string().min(1).max(255),
firstName: z.string().max(255).optional(),
lastName: z.string().max(255).optional(),
channels: z.array(ContactChannelSchema).min(1),
tags: z.array(z.string()).optional(),
status: ContactStatusSchema.optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
/** Schema for `PATCH /contacts/:id`. */
export const UpdateContactSchema = z.object({
displayName: z.string().min(1).max(255).optional(),
firstName: z.string().max(255).optional(),
lastName: z.string().max(255).optional(),
channels: z.array(ContactChannelSchema).optional(),
tags: z.array(z.string()).optional(),
status: ContactStatusSchema.optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
/** Query parameters for `GET /contacts`. */
export const ContactQuerySchema = z.object({
search: z.string().optional(),
status: ContactStatusSchema.optional(),
tags: z.string().optional(),
channelType: z.string().optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
/** Response shape for a single contact. */
export const ContactResponseSchema = z.object({
id: z.string(),
tenantId: z.string(),
displayName: z.string(),
firstName: z.string().nullable().optional(),
lastName: z.string().nullable().optional(),
channels: z.array(ContactChannelSchema),
tags: z.array(z.string()),
status: ContactStatusSchema,
metadata: z.record(z.string(), z.unknown()),
createdAt: z.string(),
updatedAt: z.string(),
});
/** Response shape for `GET /contacts`. */
export const ContactListResponseSchema = z.object({
contacts: z.array(ContactResponseSchema),
total: z.number(),
});
/**
* Build a Zod schema for `POST /contacts/import` honouring the
* runtime `maxImportBatchSize` cap. We construct it per-plugin-instance
* so the cap is enforced at the Zod boundary rather than after parsing.
*/
export function buildBulkImportSchema(maxBatchSize: number) {
return z.object({
contacts: z.array(CreateContactSchema).min(1).max(maxBatchSize),
skipDuplicates: z.boolean().default(false),
});
}
/** Static schema variant for OpenAPI introspection (uses 1000 as cap). */
export const BulkImportSchema = buildBulkImportSchema(1000);
/** Response shape for `POST /contacts/import`. */
export const BulkImportResponseSchema = z.object({
imported: z.number(),
skipped: z.number(),
errors: z.array(
z.object({
index: z.number(),
error: z.string(),
})
),
});
/** Request body for `POST /contacts/merge`. */
export const MergeContactsSchema = z.object({
primaryContactId: z.string().min(1),
mergeContactIds: z.array(z.string().min(1)).min(1),
});
/** Response body for `POST /contacts/merge`. */
export const MergeResponseSchema = z.object({
mergedContact: ContactResponseSchema,
removedContactIds: z.array(z.string()),
});

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,142 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AddMembersSchema,
CreateGroupSchema,
GroupListResponseSchema,
GroupQuerySchema,
GroupResponseSchema,
GroupTreeNodeSchema,
MembershipResponseSchema,
MembersListResponseSchema,
MoveGroupSchema,
RemoveMembersSchema,
ResolvedAudienceSchema,
UpdateGroupSchema,
} from "./groups-schemas.js";
describe("groups-schemas", () => {
it("CreateGroupSchema requires a name", { timeout: 5000 }, () => {
expect(
CreateGroupSchema.safeParse({ name: "Engineering" }).success
).toBe(true);
expect(CreateGroupSchema.safeParse({}).success).toBe(false);
});
it("UpdateGroupSchema permits sparse updates", { timeout: 5000 }, () => {
expect(UpdateGroupSchema.safeParse({ name: "x" }).success).toBe(true);
expect(UpdateGroupSchema.safeParse({}).success).toBe(true);
});
it("GroupQuerySchema coerces limit/offset", { timeout: 5000 }, () => {
const r = GroupQuerySchema.parse({ limit: "5", offset: "2" });
expect(r.limit).toBe(5);
expect(r.offset).toBe(2);
});
it("GroupResponseSchema validates full body", { timeout: 5000 }, () => {
const ok = GroupResponseSchema.safeParse({
id: "g",
tenantId: "t",
name: "x",
parentId: null,
path: "g",
depth: 0,
metadata: {},
contactCount: 0,
totalContactCount: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
expect(ok.success).toBe(true);
});
it("GroupListResponseSchema validates wrapper", { timeout: 5000 }, () => {
expect(
GroupListResponseSchema.safeParse({ groups: [], total: 0 }).success
).toBe(true);
});
it("GroupTreeNodeSchema is recursive", { timeout: 5000 }, () => {
const node = {
group: {
id: "a",
tenantId: "t",
name: "a",
parentId: null,
path: "a",
depth: 0,
metadata: {},
contactCount: 0,
totalContactCount: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
children: [],
};
const tree = { ...node, children: [node] };
expect(GroupTreeNodeSchema.safeParse(tree).success).toBe(true);
});
it("MoveGroupSchema accepts null parent", { timeout: 5000 }, () => {
expect(MoveGroupSchema.safeParse({ newParentId: null }).success).toBe(
true
);
expect(MoveGroupSchema.safeParse({ newParentId: "x" }).success).toBe(
true
);
});
it("AddMembersSchema enforces 1..500 ids", { timeout: 5000 }, () => {
expect(AddMembersSchema.safeParse({ contactIds: [] }).success).toBe(
false
);
const huge = Array.from({ length: 501 }, (_, i) => `c${i}`);
expect(AddMembersSchema.safeParse({ contactIds: huge }).success).toBe(
false
);
expect(AddMembersSchema.safeParse({ contactIds: ["a"] }).success).toBe(
true
);
});
it("RemoveMembersSchema mirrors AddMembersSchema bounds", {
timeout: 5000,
}, () => {
expect(RemoveMembersSchema.safeParse({ contactIds: [] }).success).toBe(
false
);
expect(
RemoveMembersSchema.safeParse({ contactIds: ["a"] }).success
).toBe(true);
});
it("MembershipResponseSchema validates full body", {
timeout: 5000,
}, () => {
expect(
MembershipResponseSchema.safeParse({
id: "m",
groupId: "g",
contactId: "c",
addedAt: new Date().toISOString(),
}).success
).toBe(true);
});
it("MembersListResponseSchema validates wrapper", { timeout: 5000 }, () => {
expect(
MembersListResponseSchema.safeParse({ members: [], total: 0 })
.success
).toBe(true);
});
it("ResolvedAudienceSchema validates response", { timeout: 5000 }, () => {
expect(
ResolvedAudienceSchema.safeParse({
contacts: [],
totalCount: 0,
groupsResolved: 1,
}).success
).toBe(true);
});
});

View File

@@ -0,0 +1,101 @@
import z from "zod";
import { ContactResponseSchema } from "./contacts-schemas.js";
/** Schema for `POST /groups`. */
export const CreateGroupSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().max(1000).optional(),
parentId: z.string().nullable().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
/** Schema for `PATCH /groups/:id`. */
export const UpdateGroupSchema = z.object({
name: z.string().min(1).max(255).optional(),
description: z.string().max(1000).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
/** Query parameters for `GET /groups`. */
export const GroupQuerySchema = z.object({
search: z.string().optional(),
parentId: z.string().nullable().optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
/** Response shape for a single group. */
export const GroupResponseSchema = z.object({
id: z.string(),
tenantId: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
parentId: z.string().nullable(),
path: z.string(),
depth: z.number(),
metadata: z.record(z.string(), z.unknown()),
contactCount: z.number(),
totalContactCount: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
});
/** Response shape for `GET /groups`. */
export const GroupListResponseSchema = z.object({
groups: z.array(GroupResponseSchema),
total: z.number(),
});
/**
* Recursive tree-node schema. Zod's lazy-self-reference pattern requires
* an explicit type annotation; the runtime guarantees match the
* compile-time `GroupTreeNode` from `@suaveplan/contacts`.
*/
export interface GroupTreeNodeResponse {
readonly group: z.infer<typeof GroupResponseSchema>;
readonly children: readonly GroupTreeNodeResponse[];
}
export const GroupTreeNodeSchema: z.ZodType<GroupTreeNodeResponse> = z.lazy(
() =>
z.object({
group: GroupResponseSchema,
children: z.array(GroupTreeNodeSchema),
})
);
/** Body for `POST /groups/:id/move`. */
export const MoveGroupSchema = z.object({
newParentId: z.string().nullable(),
});
/** Body for `POST /groups/:id/members`. */
export const AddMembersSchema = z.object({
contactIds: z.array(z.string().min(1)).min(1).max(500),
});
/** Body for `DELETE /groups/:id/members`. */
export const RemoveMembersSchema = z.object({
contactIds: z.array(z.string().min(1)).min(1).max(500),
});
/** Response shape for a single membership. */
export const MembershipResponseSchema = z.object({
id: z.string(),
groupId: z.string(),
contactId: z.string(),
addedAt: z.string(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
/** Response shape for `GET /groups/:id/members`. */
export const MembersListResponseSchema = z.object({
members: z.array(MembershipResponseSchema),
total: z.number(),
});
/** Response shape for `GET /groups/:id/resolve`. */
export const ResolvedAudienceSchema = z.object({
contacts: z.array(ContactResponseSchema),
totalCount: z.number(),
groupsResolved: z.number(),
});

View File

@@ -0,0 +1,34 @@
export {
BulkImportResponseSchema,
BulkImportSchema,
buildBulkImportSchema,
ContactChannelSchema,
ContactListResponseSchema,
ContactQuerySchema,
ContactResponseSchema,
ContactStatusSchema,
CreateContactSchema,
MergeContactsSchema,
MergeResponseSchema,
UpdateContactSchema,
} from "./contacts-schemas.js";
export {
AddMembersSchema,
CreateGroupSchema,
GroupListResponseSchema,
GroupQuerySchema,
GroupResponseSchema,
type GroupTreeNodeResponse,
GroupTreeNodeSchema,
MembershipResponseSchema,
MembersListResponseSchema,
MoveGroupSchema,
RemoveMembersSchema,
ResolvedAudienceSchema,
UpdateGroupSchema,
} from "./groups-schemas.js";
export {
type ContactsPluginOptionsInput,
type ContactsPluginOptionsParsed,
ContactsPluginOptionsSchema,
} from "./options.js";

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,45 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ContactsPluginOptionsSchema } from "./options.js";
describe("ContactsPluginOptionsSchema", () => {
it("applies defaults when called with empty object", {
timeout: 5000,
}, () => {
const parsed = ContactsPluginOptionsSchema.parse({});
expect(parsed.routePrefix).toBe("/");
expect(parsed.maxImportBatchSize).toBe(1000);
expect(parsed.maxGroupDepth).toBe(10);
expect(parsed.enforceGroupDepth).toBe(true);
expect(parsed.applySchemaOnInit).toBe(true);
expect(parsed.logger).toBeUndefined();
});
it("rejects routePrefix without leading slash", { timeout: 5000 }, () => {
const r = ContactsPluginOptionsSchema.safeParse({ routePrefix: "x" });
expect(r.success).toBe(false);
});
it("rejects non-positive maxGroupDepth", { timeout: 5000 }, () => {
const r = ContactsPluginOptionsSchema.safeParse({ maxGroupDepth: -1 });
expect(r.success).toBe(false);
});
it("rejects non-integer maxImportBatchSize", { timeout: 5000 }, () => {
const r = ContactsPluginOptionsSchema.safeParse({
maxImportBatchSize: 1.5,
});
expect(r.success).toBe(false);
});
it("preserves a supplied logger", { timeout: 5000 }, () => {
const noop = (): undefined => undefined;
const logger = {
debug: noop,
info: noop,
warn: noop,
error: noop,
};
const parsed = ContactsPluginOptionsSchema.parse({ logger });
expect(parsed.logger).toBeDefined();
});
});

View File

@@ -0,0 +1,44 @@
import z from "zod";
/**
* Zod schema for `createContactsPlugin(options)`. All fields optional;
* defaults are applied at parse time so downstream code reads a fully
* resolved `ContactsPluginConfig` regardless of which fields the caller
* supplied.
*/
export const ContactsPluginOptionsSchema = z.object({
routePrefix: z
.string()
.min(1, "routePrefix must be non-empty")
.startsWith("/", "routePrefix must start with '/'")
.default("/"),
maxImportBatchSize: z
.number()
.int("maxImportBatchSize must be an integer")
.positive("maxImportBatchSize must be > 0")
.default(1000),
maxGroupDepth: z
.number()
.int("maxGroupDepth must be an integer")
.positive("maxGroupDepth must be > 0")
.default(10),
enforceGroupDepth: z.boolean().default(true),
applySchemaOnInit: z.boolean().default(true),
logger: z
.object({
debug: z.function(),
info: z.function(),
warn: z.function(),
error: z.function(),
})
.optional(),
});
/** Inferred input shape with all fields optional. */
export type ContactsPluginOptionsInput = z.input<
typeof ContactsPluginOptionsSchema
>;
/** Inferred output shape with defaults applied. */
export type ContactsPluginOptionsParsed = z.output<
typeof ContactsPluginOptionsSchema
>;

View File

@@ -0,0 +1 @@
import "./registry-augmentation.js";

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,35 @@
/**
* ServiceRegistry module augmentation — exposes the three SurrealDB-backed
* contact repositories as typed slots on the shared
* `@suaveplan/api-plugin/service-registry` `ServiceRegistry` interface so
* sibling plugins can resolve them via `ctx.getService(...)` without a
* cast. Imported for its side effects by the package root barrel.
*/
import type { OpenAPIHono } from "@hono/zod-openapi";
import type { MigrationRunner } from "@suaveplan/api-migrations";
import type { OpenAPIRegistry } from "@suaveplan/api-openapi";
import type { AuthService } from "@suaveplan/api-plugin-auth";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import type { Clock, EventBusLike, LoggerLike } from "@suaveplan/types";
import type { SurrealContactRepository } from "../repositories/contact-repository.js";
import type { SurrealGroupRepository } from "../repositories/group-repository.js";
import type { SurrealMembershipRepository } from "../repositories/membership-repository.js";
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
contactRepository: SurrealContactRepository;
groupRepository: SurrealGroupRepository;
membershipRepository: SurrealMembershipRepository;
// Host-side and sibling-plugin slots — re-declared so the
// contacts plugin's `onInit(ctx)` can resolve them with full
// typing even when this plugin is imported in isolation.
authService: AuthService;
app: OpenAPIHono;
openAPIRegistry: OpenAPIRegistry;
migrationRunner?: MigrationRunner;
db?: SurrealAdapter;
logger: LoggerLike;
eventBus: EventBusLike;
clock: Clock;
}
}

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,63 @@
import type { Group } from "@suaveplan/contacts";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildTree } from "./build-tree.js";
function g(id: string, parentId: string | null): Group {
return {
id,
tenantId: "t",
name: id,
parentId,
path: id,
depth: 0,
metadata: {},
contactCount: 0,
totalContactCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
}
describe("buildTree", () => {
it("returns a leaf node with empty children when no descendants supplied", {
timeout: 5000,
}, () => {
const root = g("root", null);
expect(buildTree(root, [])).toEqual({ group: root, children: [] });
});
it("builds a nested tree from arbitrary descendant order", {
timeout: 5000,
}, () => {
const root = g("root", null);
const a = g("a", "root");
const b = g("b", "root");
const a1 = g("a1", "a");
const a2 = g("a2", "a");
const tree = buildTree(root, [a2, a1, b, a]);
expect(tree.children).toHaveLength(2);
const aNode = tree.children.find((c) => c.group.id === "a");
expect(aNode?.children.map((c) => c.group.id).sort()).toEqual([
"a1",
"a2",
]);
});
it("ignores descendants whose parentId does not appear in the tree", {
timeout: 5000,
}, () => {
const root = g("root", null);
const orphan = g("orphan", "missing-parent");
const tree = buildTree(root, [orphan]);
expect(tree.children).toHaveLength(0);
});
it("buckets descendants whose parentId is null under the empty key", {
timeout: 5000,
}, () => {
const root = g("root", null);
const stray = g("stray", null);
const tree = buildTree(root, [stray]);
expect(tree.children).toHaveLength(0);
});
});

View File

@@ -0,0 +1,41 @@
/**
* Typed subtree builder. Given a root `Group` and a flat list of its
* descendants (in arbitrary order), produces a nested `GroupTreeNode`
* forest rooted at the supplied group. Replaces the upstream port's
* `function buildTree(root: any, descendants: any[]): any`.
*/
import type { Group, GroupTreeNode } from "@suaveplan/contacts";
/**
* Build a typed tree rooted at `root` from a flat `descendants` list.
* Children are derived by `parentId === root.id`; the result is
* deterministic insofar as the input order is deterministic — the
* caller is responsible for sorting descendants if a deterministic
* traversal order is required.
*/
export function buildTree(
root: Group,
descendants: readonly Group[]
): GroupTreeNode {
const childrenByParent = new Map<string, Group[]>();
for (const d of descendants) {
const key =
d.parentId !== null && d.parentId !== undefined ? d.parentId : "";
const bucket = childrenByParent.get(key);
if (bucket === undefined) {
childrenByParent.set(key, [d]);
} else {
bucket.push(d);
}
}
return walk(root, childrenByParent);
}
function walk(node: Group, byParent: Map<string, Group[]>): GroupTreeNode {
const children = byParent.get(node.id) ?? [];
return {
group: node,
children: children.map((child) => walk(child, byParent)),
};
}

View File

@@ -0,0 +1 @@
export { buildTree } from "./build-tree.js";

View File

@@ -0,0 +1,4 @@
export type {
ContactsPluginConfig,
ContactsPluginOptions,
} from "./types.js";

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.

View File

@@ -0,0 +1,35 @@
/**
* Public type bundle for `@suaveplan/api-plugin-contacts`. Only the
* config + descriptor shapes that consumers depend on at compile time
* live here. Hono `ContextVariableMap` augmentations live in
* `./context.ts`; ServiceRegistry augmentations live in
* `./registry-augmentation.ts`.
*/
import type { LoggerLike } from "@suaveplan/types";
/** Validated config carried by the descriptor's `defaults` slot. */
export interface ContactsPluginConfig {
/** Route mount prefix (default `"/"`). */
readonly routePrefix: string;
/** Maximum batch size accepted by `POST /contacts/import`. Default 1000. */
readonly maxImportBatchSize: number;
/** Maximum group nesting depth enforced by `SurrealGroupRepository`. Default 10. */
readonly maxGroupDepth: number;
/** When `true`, repositories raise `CONTACTS_API.GROUP_DEPTH_EXCEEDED`. Default `true`. */
readonly enforceGroupDepth: boolean;
/** When `true`, plugin `onInit` applies bundled SurrealQL schema. Default `true`. */
readonly applySchemaOnInit: boolean;
/** Optional override logger; falls back to `ctx.logger`. */
readonly logger?: LoggerLike;
}
/** Raw options accepted by {@link createContactsPlugin}. All fields optional. */
export interface ContactsPluginOptions {
readonly routePrefix?: string;
readonly maxImportBatchSize?: number;
readonly maxGroupDepth?: number;
readonly enforceGroupDepth?: boolean;
readonly applySchemaOnInit?: boolean;
readonly logger?: LoggerLike;
}

View File

@@ -0,0 +1,21 @@
-- Contact table — tenant scoping enforced at the handler layer via Principal.tenantId.
DEFINE TABLE OVERWRITE contact SCHEMAFULL;
DEFINE FIELD OVERWRITE tenantId ON contact TYPE string ASSERT $value != NONE;
DEFINE FIELD OVERWRITE displayName ON contact TYPE string ASSERT $value != NONE;
DEFINE FIELD OVERWRITE firstName ON contact TYPE option<string>;
DEFINE FIELD OVERWRITE lastName ON contact TYPE option<string>;
DEFINE FIELD OVERWRITE channels ON contact TYPE array<object>;
DEFINE FIELD OVERWRITE channels.*.type ON contact TYPE string;
DEFINE FIELD OVERWRITE channels.*.value ON contact TYPE string;
DEFINE FIELD OVERWRITE channels.*.primary ON contact TYPE bool DEFAULT false;
DEFINE FIELD OVERWRITE channels.*.verified ON contact TYPE bool DEFAULT false;
DEFINE FIELD OVERWRITE tags ON contact TYPE array<string> DEFAULT [];
DEFINE FIELD OVERWRITE status ON contact TYPE string DEFAULT 'active' ASSERT $value IN ['active', 'unsubscribed', 'bounced', 'complained', 'archived'];
DEFINE FIELD OVERWRITE metadata ON contact TYPE object DEFAULT {};
DEFINE FIELD OVERWRITE createdAt ON contact TYPE datetime DEFAULT time::now();
DEFINE FIELD OVERWRITE updatedAt ON contact TYPE datetime VALUE time::now();
DEFINE INDEX OVERWRITE idx_contact_tenant ON contact FIELDS tenantId;
DEFINE INDEX OVERWRITE idx_contact_status ON contact FIELDS tenantId, status;
-- Note: channel-value indexes are intentionally omitted; channel lookups use array filters at query time.

View File

@@ -0,0 +1,18 @@
-- Group table with hierarchical nesting — tenant scoping at the handler layer.
DEFINE TABLE OVERWRITE contact_group SCHEMAFULL;
DEFINE FIELD OVERWRITE tenantId ON contact_group TYPE string ASSERT $value != NONE;
DEFINE FIELD OVERWRITE name ON contact_group TYPE string ASSERT $value != NONE;
DEFINE FIELD OVERWRITE description ON contact_group TYPE option<string>;
DEFINE FIELD OVERWRITE parentId ON contact_group TYPE option<string>;
DEFINE FIELD OVERWRITE path ON contact_group TYPE string ASSERT $value != NONE;
DEFINE FIELD OVERWRITE depth ON contact_group TYPE int DEFAULT 0;
DEFINE FIELD OVERWRITE metadata ON contact_group TYPE object DEFAULT {};
DEFINE FIELD OVERWRITE contactCount ON contact_group TYPE int DEFAULT 0;
DEFINE FIELD OVERWRITE totalContactCount ON contact_group TYPE int DEFAULT 0;
DEFINE FIELD OVERWRITE createdAt ON contact_group TYPE datetime DEFAULT time::now();
DEFINE FIELD OVERWRITE updatedAt ON contact_group TYPE datetime VALUE time::now();
DEFINE INDEX OVERWRITE idx_group_tenant ON contact_group FIELDS tenantId;
DEFINE INDEX OVERWRITE idx_group_parent ON contact_group FIELDS tenantId, parentId;
DEFINE INDEX OVERWRITE idx_group_path ON contact_group FIELDS path;

View File

@@ -0,0 +1,11 @@
-- Group membership (many-to-many).
DEFINE TABLE OVERWRITE group_membership SCHEMAFULL;
DEFINE FIELD OVERWRITE groupId ON group_membership TYPE string ASSERT $value != NONE;
DEFINE FIELD OVERWRITE contactId ON group_membership TYPE string ASSERT $value != NONE;
DEFINE FIELD OVERWRITE metadata ON group_membership TYPE object DEFAULT {};
DEFINE FIELD OVERWRITE addedAt ON group_membership TYPE datetime DEFAULT time::now();
DEFINE INDEX OVERWRITE idx_membership_unique ON group_membership FIELDS groupId, contactId UNIQUE;
DEFINE INDEX OVERWRITE idx_membership_group ON group_membership FIELDS groupId;
DEFINE INDEX OVERWRITE idx_membership_contact ON group_membership FIELDS contactId;

View File

@@ -0,0 +1,109 @@
/**
* Pins per-row error reporting + skipDuplicates against real SurrealDB.
*/
import "@suaveplan/api-plugin-auth";
import { OpenAPIHono } from "@hono/zod-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { SurrealAdapter } from "@suaveplan/surrealdb";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
import { applyContactsSchema, createContactsPlugin } from "../../src/index.js";
let fixture: SurrealDBFixture | null = null;
let client: SurrealAdapter | null = null;
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
beforeAll(async () => {
fixture = new SurrealDBFixture();
const cfg = await fixture.start();
client = new SurrealAdapter({
url: cfg.url,
namespace: cfg.namespace,
database: cfg.database,
username: cfg.username,
password: cfg.password,
});
await client.connect();
await applyContactsSchema({ db: client });
}, 180_000);
afterAll(async () => {
if (client !== null) {
await client.disconnect();
client = null;
}
if (fixture !== null) {
await fixture.stop();
fixture = null;
}
}, 60_000);
describe("api-plugin-contacts bulk-import integration", () => {
it("per-row errors are reported with the original index against real SurrealDB", {
timeout: 60_000,
}, async () => {
if (client === null) throw new Error("client not ready");
const app = new OpenAPIHono();
const services = new Map<string, unknown>();
services.set("app", app);
services.set("db", client);
const ctx = createPluginContext({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: {} as never,
services,
});
const desc = createContactsPlugin({
applySchemaOnInit: false,
maxImportBatchSize: 5,
});
await desc.onInit?.(ctx as never);
// Mount a fake auth middleware that injects a tenantId.
const wrapped = new OpenAPIHono();
wrapped.use("*", async (c, next) => {
c.set("user", { id: "u", tenantId: "t-import" } as never);
await next();
});
wrapped.route("/", app);
const res = await wrapped.request("/contacts/import", {
method: "POST",
body: JSON.stringify({
contacts: [
{
displayName: "ok",
channels: [{ type: "email", value: "ok@example.com" }],
},
{
displayName: "ok2",
channels: [{ type: "email", value: "ok2@example.com" }],
},
],
}),
headers: { "content-type": "application/json" },
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { imported: number; skipped: number; errors: unknown[] };
};
expect(body.data.imported).toBe(2);
expect(body.data.errors).toEqual([]);
});
});

View File

@@ -0,0 +1,154 @@
/**
* Full-boot integration test against a real SurrealDB v3 testcontainer.
* Applies the bundled schema and walks the full contacts + groups +
* memberships roundtrip via the three repositories.
*/
import { SurrealAdapter } from "@suaveplan/surrealdb";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
import {
applyContactsSchema,
getContactsApiInstruments,
resetContactsApiInstruments,
SurrealContactRepository,
SurrealGroupRepository,
SurrealMembershipRepository,
} from "../../src/index.js";
let fixture: SurrealDBFixture | null = null;
let client: SurrealAdapter | null = null;
beforeAll(async () => {
fixture = new SurrealDBFixture();
const cfg = await fixture.start();
client = new SurrealAdapter({
url: cfg.url,
namespace: cfg.namespace,
database: cfg.database,
username: cfg.username,
password: cfg.password,
});
await client.connect();
resetContactsApiInstruments();
}, 180_000);
afterAll(async () => {
if (client !== null) {
await client.disconnect();
client = null;
}
if (fixture !== null) {
await fixture.stop();
fixture = null;
}
}, 60_000);
describe("api-plugin-contacts full-boot integration", () => {
it("onInit applies surql tables against real SurrealDB", {
timeout: 120_000,
}, async () => {
if (client === null) throw new Error("client not ready");
await applyContactsSchema({ db: client });
const [info] = await client.query<unknown[]>("INFO FOR DB");
expect(info).toBeDefined();
});
it("contact roundtrip against real SurrealDB", {
timeout: 60_000,
}, async () => {
if (client === null) throw new Error("client not ready");
const repo = new SurrealContactRepository(client);
const created = await repo.create({
tenantId: "t-1",
displayName: "Ada Lovelace",
channels: [
{
type: "email",
value: "ada@example.com",
primary: true,
verified: false,
},
],
});
expect(created.id.length).toBeGreaterThan(0);
const fetched = await repo.getById(created.id);
expect(fetched?.displayName).toBe("Ada Lovelace");
const updated = await repo.update(created.id, {
displayName: "Ada L.",
});
expect(updated.displayName).toBe("Ada L.");
const count = await repo.countByTenant("t-1");
expect(count).toBeGreaterThanOrEqual(1);
await repo.delete(created.id);
});
it("full contact + group + membership roundtrip", {
timeout: 60_000,
}, async () => {
if (client === null) throw new Error("client not ready");
const contactRepo = new SurrealContactRepository(client);
const groupRepo = new SurrealGroupRepository(client, {
maxGroupDepth: 5,
enforceGroupDepth: true,
});
const memberRepo = new SurrealMembershipRepository(client);
const contact = await contactRepo.create({
tenantId: "t-2",
displayName: "Grace Hopper",
channels: [
{
type: "email",
value: "grace@example.com",
primary: true,
verified: false,
},
],
});
const root = await groupRepo.createGroup({
tenantId: "t-2",
name: "Pioneers",
});
await memberRepo.add({
id: memberRepo.generateId(),
groupId: root.id,
contactId: contact.id,
addedAt: new Date(),
});
const members = await memberRepo.getByGroup(root.id);
expect(members.length).toBe(1);
expect(await memberRepo.exists(root.id, contact.id)).toBe(true);
await memberRepo.remove(root.id, contact.id);
await contactRepo.delete(contact.id);
await groupRepo.delete(root.id);
});
it("emits contacts_api.contact_writes_total on POST /contacts", {
timeout: 60_000,
}, async () => {
if (client === null) {
throw new Error("client not ready");
}
// Re-bind to the global polyfill MeterProvider installed in
// tests/setup/polyfills.ts so flushTestMetrics() sees the
// increments emitted by getContactsApiInstruments().
const { flushTestMetrics } = await import("../setup/polyfills.js");
resetContactsApiInstruments();
getContactsApiInstruments().contactWritesTotal.add(1, {
outcome: "success",
});
const collected = await flushTestMetrics();
const names = collected.flatMap((r) =>
r.scopeMetrics.flatMap((s) =>
s.metrics.map((m) => m.descriptor.name)
)
);
expect(names).toContain("contacts_api.contact_writes_total");
});
});

View File

@@ -0,0 +1,104 @@
/**
* Pins the `maxGroupDepth` repository guard against real SurrealDB.
*/
import { SurrealAdapter } from "@suaveplan/surrealdb";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
import {
applyContactsSchema,
CONTACTS_API_CODES,
SurrealGroupRepository,
} from "../../src/index.js";
let fixture: SurrealDBFixture | null = null;
let client: SurrealAdapter | null = null;
beforeAll(async () => {
fixture = new SurrealDBFixture();
const cfg = await fixture.start();
client = new SurrealAdapter({
url: cfg.url,
namespace: cfg.namespace,
database: cfg.database,
username: cfg.username,
password: cfg.password,
});
await client.connect();
await applyContactsSchema({ db: client });
}, 180_000);
afterAll(async () => {
if (client !== null) {
await client.disconnect();
client = null;
}
if (fixture !== null) {
await fixture.stop();
fixture = null;
}
}, 60_000);
describe("api-plugin-contacts group-depth integration", () => {
it("createGroup raises GROUP_DEPTH_EXCEEDED when enforceGroupDepth is on", {
timeout: 60_000,
}, async () => {
if (client === null) throw new Error("client not ready");
const repo = new SurrealGroupRepository(client, {
maxGroupDepth: 2,
enforceGroupDepth: true,
});
const a = await repo.createGroup({ tenantId: "t-depth", name: "A" });
const b = await repo.createGroup({
tenantId: "t-depth",
name: "B",
parentId: a.id,
});
// b.depth === 1; another child would be depth 2 == maxGroupDepth (>=).
await expect(
repo.createGroup({
tenantId: "t-depth",
name: "C",
parentId: b.id,
})
).rejects.toMatchObject({
code: CONTACTS_API_CODES.GROUP_DEPTH_EXCEEDED,
});
await repo.delete(b.id);
await repo.delete(a.id);
});
it("createGroup permits any depth when enforceGroupDepth is false", {
timeout: 60_000,
}, async () => {
if (client === null) throw new Error("client not ready");
const repo = new SurrealGroupRepository(client, {
maxGroupDepth: 1,
enforceGroupDepth: false,
});
const a = await repo.createGroup({
tenantId: "t-depth-off",
name: "A",
});
const b = await repo.createGroup({
tenantId: "t-depth-off",
name: "B",
parentId: a.id,
});
const c = await repo.createGroup({
tenantId: "t-depth-off",
name: "C",
parentId: b.id,
});
expect(c.depth).toBe(2);
await repo.delete(c.id);
await repo.delete(b.id);
await repo.delete(a.id);
});
});

View File

@@ -0,0 +1,32 @@
/**
* Test setup for @suaveplan/api-plugin-contacts.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any
* source module imports so the meter resolved on first call binds to it.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-plugin-contacts",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^@hono\//,
"hono",
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 60000,
hookTimeout: 180000,
},
});