first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
# `contacts`
Contact and group management with deduplication and import/export.
<!-- begin:auto-docs -->
## Packages in `contacts`
4 packages.
| Package | Description |
|---|---|
| [`@suaveplan/api-plugin-contacts`](./api-plugin-contacts/README.md) | Contacts and Groups HTTP plugin for `@suaveplan/api`. Mounts the `/contacts` and `/groups` route trees on a Hono `OpenAPIHono`, backed by SurrealDB implementat… |
| [`@suaveplan/audience-rules`](./audience-rules/README.md) | Universal audience segmentation: rule-based filtering on contact data with 14 comparison operators, AND/OR chaining, dot-notation field resolution including ar… |
| [`@suaveplan/contacts`](./contacts/README.md) | Universal contact and group management with hierarchical nesting, audience resolution, contact merger, and CSV/JSON import-export. Multi-tenant, channel-agnost… |
| [`@suaveplan/macos-contacts`](./macos-contacts/README.md) | Universal bridge to macOS Contacts.app via AppleScript. Reads contacts and groups through the platform `osascript` binary, parses the output into typed records… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Contact and group management with deduplication and import/export.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,111 @@
# @suaveplan/audience-rules - Universal audience segmentation: rule-based filtering with 14 comparison operators, AND/OR chaining, dot-notation field resolution including array indices, three refresh modes (realtime/scheduled/manual), seven predicate factories, pluggable SegmentStore with InMemorySegmentStore (Clock + IdGenerator injectable), SegmentRefresher with typed EventBus emission, Zod-validated boundaries, and AudienceRulesError with stable AUDIENCE_RULES.* code registry.
Universal audience segmentation: rule-based filtering on contact data
with 14 comparison operators, AND/OR chaining, dot-notation field
resolution including array indices, three refresh modes
(realtime/scheduled/manual), seven predicate factory helpers, a pluggable
`SegmentStore` with an `InMemorySegmentStore` (injectable `Clock` +
`IdGenerator`), and a `SegmentRefresher` that emits `segment:refreshed`
on a typed `EventBus<SegmentRefresherEventMap>`. Inputs are
Zod-validated; failures raise `AudienceRulesError` with a stable
`AUDIENCE_RULES.*` code registry.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` defines the stable, namespaced error-code registry for the `@suaveplan/audience-rules` package, alongs…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` defines the stable, namespaced error-code registry for the `@suaveplan/audience-rules` package, alongs…
- [predicates](./src/predicates/predicates.md) — `predicates.ts` ships seven factory functions that produce `SegmentRule` values for the most common audience filters: t…
- [predicates](./src/predicates/predicates.md) — `predicates.ts` ships seven factory functions that produce `SegmentRule` values for the most common audience filters: t…
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exposes the Zod validators that guard every public input boundary on the `@suaveplan/audience-rules` store…
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exposes the Zod validators that guard every public input boundary on the `@suaveplan/audience-rules` store…
- [segment-evaluator](./src/segment-evaluator/segment-evaluator.md) — `segment-evaluator.ts` provides the pure-function rule engine that decides, for a given `Segment` and a tenant's `Conta…
- [segment-evaluator](./src/segment-evaluator/segment-evaluator.md) — `segment-evaluator.ts` provides the pure-function rule engine that decides, for a given `Segment` and a tenant's `Conta…
- [segment-refresher](./src/segment-refresher/segment-refresher.md) — `segment-refresher.ts` orchestrates re-evaluation of one or many segments and persists the resulting `contactIds` back…
- [segment-refresher](./src/segment-refresher/segment-refresher.md) — `segment-refresher.ts` orchestrates re-evaluation of one or many segments and persists the resulting `contactIds` back…
- [store](./src/store/store.md) — `store.ts` defines the `SegmentStore` interface that abstracts segment persistence behind a six-method contract (`creat…
- [store](./src/store/store.md) — `store.ts` defines the `SegmentStore` interface that abstracts segment persistence behind a six-method contract (`creat…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/audience-rules
```
## Quick start
```ts
import {
InMemorySegmentStore,
SegmentEvaluator,
SegmentRefresher,
hasTag,
statusIs,
} from "@suaveplan/audience-rules";
const provider = async () => [
{ id: "c1", tags: ["vip"], status: "active" },
{ id: "c2", tags: ["basic"], status: "active" },
{ id: "c3", tags: ["vip"], status: "unsubscribed" },
];
const store = new InMemorySegmentStore();
const evaluator = new SegmentEvaluator(provider);
const refresher = new SegmentRefresher(store, evaluator);
const segment = await store.create({
tenantId: "tenant-1",
name: "Active VIPs",
rules: [hasTag("vip"), statusIs("active")],
refreshMode: "manual",
});
refresher.events.on("segment:refreshed", ({ segmentId, result }) => {
console.log(`${segmentId}: ${result.total} matches`);
});
await refresher.refresh(segment.id);
```
## Modules
- [error-codes](src/error-codes/error-codes.md) — `AUDIENCE_RULES_CODES`
registry + `AudienceRulesError`.
- [schemas](src/schemas/schemas.md) — Zod validators + `parseOrThrow`.
- [store](src/store/store.md) — `SegmentStore` interface +
`InMemorySegmentStore` with `Clock` + `IdGenerator` injection.
- [segment-evaluator](src/segment-evaluator/segment-evaluator.md) —
14-operator rule engine with dot-notation field resolution and AND/OR
chaining.
- [segment-refresher](src/segment-refresher/segment-refresher.md) —
re-evaluation orchestrator with typed `EventBus` emission.
- [predicates](src/predicates/predicates.md) — seven `SegmentRule` factory
helpers for tags, channels, status, activity dates, and metadata.
## Features
- 14 operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`,
`not_contains`, `in`, `not_in`, `exists`, `not_exists`, `starts_with`,
`ends_with`.
- AND/OR logical chaining via `logicalOp` on each rule.
- Dot-notation field paths with numeric array indices
(`channels.0.type`).
- Three refresh modes for cache policy: `realtime`, `scheduled`, `manual`.
- Multi-tenant isolation via `tenantId` on `Segment` and `list`.
- Predicate factories for the seven most common audience filters.
- Pluggable storage — provide a `SegmentStore` implementation backed by
the persistence layer of your choice. `InMemorySegmentStore` is the
reference implementation.
- Typed event emission via `EventBus<SegmentRefresherEventMap>` from
`@suaveplan/events`.
- Stable `AUDIENCE_RULES.*` code registry with `INVALID_INPUT`,
`SEGMENT_NOT_FOUND`, `UNKNOWN_OPERATOR`, `PROVIDER_FAILED`.
## See also
- `@suaveplan/contacts` — contact and group management, provides the
`ContactData` consumed by audience-rules.
- `@suaveplan/events` — typed `EventBus<TMap>` substrate.
- `@suaveplan/error` — `SuaveplanError` + `defineCodes`.

View File

@@ -0,0 +1,93 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-codes.ts` defines the stable, namespaced error-code registry for the
`@suaveplan/audience-rules` package, alongside the discriminated
`AudienceRulesError` class that every failure path raises. It exists so that
consumers can dispatch on a stable string (`"AUDIENCE_RULES.SEGMENT_NOT_FOUND"`)
rather than parsing error messages, and so that diagnostic context
(`segmentId` / `tenantId`) survives transport across logger sinks, OTel span
events, and structured-output channels without polluting the base
`SuaveplanError` `context` map.
## Features
- `AUDIENCE_RULES_CODES` registry built via `defineCodes("AUDIENCE_RULES", [...])`
with four stable codes: `INVALID_INPUT`, `SEGMENT_NOT_FOUND`,
`UNKNOWN_OPERATOR`, `PROVIDER_FAILED`.
- `AudienceRulesErrorCode` union type narrowing every legal `code` value.
- `AudienceRulesError extends SuaveplanError<AudienceRulesErrorCode>` carrying
optional `segmentId` and `tenantId` discriminants.
- `AudienceRulesErrorOptions` extending `SuaveplanErrorOptions` with the two
discriminant fields.
- Compatible with the `@suaveplan/error` cause-chain traversal, redaction, and
OTel span-event adapter without further wiring.
## Usage: Basic Example
```ts
import {
AUDIENCE_RULES_CODES,
AudienceRulesError,
} from "@suaveplan/audience-rules";
throw new AudienceRulesError(
AUDIENCE_RULES_CODES.SEGMENT_NOT_FOUND,
"segment 'seg_42' does not exist",
{ segmentId: "seg_42", tenantId: "tenant-1" }
);
```
## Usage: Advanced Example
```ts
import {
AudienceRulesError,
AUDIENCE_RULES_CODES,
} from "@suaveplan/audience-rules";
function handleRefresh(error: unknown) {
if (
error instanceof AudienceRulesError &&
error.code === AUDIENCE_RULES_CODES.PROVIDER_FAILED
) {
// narrow tenantId for diagnostics, then re-throw with extra context
throw new AudienceRulesError(
AUDIENCE_RULES_CODES.PROVIDER_FAILED,
"contact provider failed during scheduled refresh",
{ tenantId: error.tenantId, cause: error }
);
}
throw error;
}
```
## API Reference
- `AUDIENCE_RULES_CODES` — frozen record mapping every short name to its
namespaced string (e.g. `INVALID_INPUT → "AUDIENCE_RULES.INVALID_INPUT"`).
- `type AudienceRulesErrorCode` — union of every namespaced string value.
- `interface AudienceRulesErrorOptions extends SuaveplanErrorOptions` — accepts
`segmentId?: string`, `tenantId?: string`, plus every option from the base
`SuaveplanErrorOptions` (e.g. `cause`, `context`).
- `class AudienceRulesError extends SuaveplanError<AudienceRulesErrorCode>` —
constructor `(code, message, options?)`; exposes `segmentId?: string` and
`tenantId?: string` as readonly own-properties.
## Implementation Notes
- The registry is intentionally small at v0.1.0. Adding a code requires bumping
the package and updating the OpenSpec delta; deleting a code is a breaking
change. See `AGENTS.md §2c` — codes are never removed just because a
call site has not been wired yet.
- `segmentId` / `tenantId` are assigned only when the constructor option is
non-`undefined`, so the runtime shape stays compact for logger redaction
passes that walk own-property keys.
- `defineCodes` from `@suaveplan/error` guarantees the namespaced prefix at
type-time, so a typo in the literal (`"AUDIENCE_RULE.X"`) is a compile error,
not a silent registry shift.

View File

@@ -0,0 +1,78 @@
# predicates
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`predicates.ts` ships seven factory functions that produce `SegmentRule`
values for the most common audience filters: tag membership, channel type,
status equality, activity-date windows, and metadata field equality /
substring. Centralising these factories trims boilerplate when building
segments programmatically (e.g. from a UI builder) and pins the field-path
convention (`tags`, `channels.0.type`, `metadata.<field>`) so callers do not
have to remember it.
## Features
- `hasTag(tag)` — matches `tags` contains tag.
- `channelIs(type)` — matches first channel of given type.
- `statusIs(status)` — matches exact status string.
- `lastActivityBefore(date)` — matches `lastActivityAt < date.toISOString()`.
- `lastActivityAfter(date)` — matches `lastActivityAt > date.toISOString()`.
- `metadataEquals(field, value)` — matches `metadata.<field> === value`.
- `metadataContains(field, value)` — matches `metadata.<field>` contains
value (string substring or array membership).
## Usage: Basic Example
```ts
import { hasTag, statusIs } from "@suaveplan/audience-rules";
const rules = [hasTag("vip"), statusIs("active")];
```
## Usage: Advanced Example
```ts
import {
channelIs,
lastActivityAfter,
metadataEquals,
type SegmentRule,
} from "@suaveplan/audience-rules";
// "US-based contacts who opened email since Jan 1, OR have a US push channel"
const rules: SegmentRule[] = [
metadataEquals("country", "US"),
lastActivityAfter(new Date("2026-01-01")),
{ ...channelIs("push"), logicalOp: "or" },
];
```
## API Reference
- `hasTag(tag: string): SegmentRule`
- `channelIs(type: string): SegmentRule`
- `statusIs(status: string): SegmentRule`
- `lastActivityBefore(date: Date): SegmentRule`
- `lastActivityAfter(date: Date): SegmentRule`
- `metadataEquals(field: string, value: unknown): SegmentRule`
- `metadataContains(field: string, value: unknown): SegmentRule`
Each factory returns a `SegmentRule` with `logicalOp` left undefined, so the
default `"and"` chaining applies. Override `logicalOp` via spread to OR-chain
a predicate with the previous rule.
## Implementation Notes
- The date predicates serialise the boundary date as ISO 8601. The
evaluator's `gt` / `lt` operators compare via unchecked numeric casts,
which works because ISO date strings are lexicographically ordered.
- `channelIs` targets `channels.0.type` rather than walking the full
channel array; this matches the upstream convention and reflects the
fact that contacts are typically created with a primary channel at
index 0.
- All seven factories are pure and stateless, so they are safe to call in
hot paths (e.g. on every keystroke in a segment builder UI).

View File

@@ -0,0 +1,96 @@
# schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`schemas.ts` exposes the Zod validators that guard every public input
boundary on the `@suaveplan/audience-rules` store API, plus the `parseOrThrow`
helper that converts a `ZodError` into a typed `AudienceRulesError` with code
`AUDIENCE_RULES.INVALID_INPUT`. Centralising the validators in one module
keeps the constraint surface auditable and lets consumers reuse the same
schemas (e.g. for HTTP-layer body parsing in a future `api-plugin-audience-rules`)
without depending on internal helpers.
## Features
- `segmentOperatorSchema` — Zod enum mirroring the 14 `SegmentOperator`
values.
- `logicalOpSchema` — Zod enum for `"and" | "or"`.
- `refreshModeSchema` — Zod enum for `"realtime" | "scheduled" | "manual"`.
- `segmentRuleSchema` — validates `{ field, operator, value?, logicalOp? }`.
- `createSegmentInputSchema` — validates the full `CreateSegmentInput`.
- `updateSegmentInputSchema` — validates the partial `UpdateSegmentInput`
and rejects an empty patch (all fields undefined).
- `parseOrThrow(schema, input, label)` — runs `safeParse`, returns the typed
value on success, throws `AudienceRulesError` with `INVALID_INPUT` and a
human-readable `path: message` summary on failure.
## Usage: Basic Example
```ts
import {
createSegmentInputSchema,
parseOrThrow,
} from "@suaveplan/audience-rules";
const input = parseOrThrow(
createSegmentInputSchema,
{ tenantId: "t1", name: "VIPs", rules: [], refreshMode: "manual" },
"createSegment"
);
```
## Usage: Advanced Example
```ts
import {
AUDIENCE_RULES_CODES,
AudienceRulesError,
parseOrThrow,
segmentRuleSchema,
} from "@suaveplan/audience-rules";
try {
parseOrThrow(
segmentRuleSchema,
{ field: "", operator: "eq", value: "x" },
"segmentRule"
);
} catch (err) {
if (
err instanceof AudienceRulesError &&
err.code === AUDIENCE_RULES_CODES.INVALID_INPUT
) {
// err.cause is the original ZodError with the full issues array
}
}
```
## API Reference
- `segmentOperatorSchema: z.ZodEnum<SegmentOperator>`
- `logicalOpSchema: z.ZodEnum<LogicalOp>`
- `refreshModeSchema: z.ZodEnum<RefreshMode>`
- `segmentRuleSchema: z.ZodObject<{...}>` — produces a `SegmentRule`.
- `createSegmentInputSchema: z.ZodObject<{...}>` — produces a
`CreateSegmentInput`.
- `updateSegmentInputSchema: z.ZodObject<{...}>` — produces an
`UpdateSegmentInput`; refines that at least one optional field is present.
- `parseOrThrow<T>(schema: z.ZodType<T>, input: unknown, label: string): T` —
returns parsed value, or throws `AudienceRulesError` with code
`AUDIENCE_RULES.INVALID_INPUT` and the original `ZodError` preserved in
`cause`.
## Implementation Notes
- Schemas use `z.string().min(1)` for identity-like fields (`tenantId`,
`name`, `field`) so empty strings are rejected at the boundary.
- The `updateSegmentInputSchema` `.refine` predicate prevents callers from
shipping an empty patch — every `update` MUST change at least one field, or
it is rejected with `INVALID_INPUT`.
- The error message produced by `parseOrThrow` lists every issue as
`path: message; path: message` so server logs and OTel span events surface
every validation failure, not just the first.

View File

@@ -0,0 +1,120 @@
# segment-evaluator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`segment-evaluator.ts` provides the pure-function rule engine that decides,
for a given `Segment` and a tenant's `ContactData[]`, which contacts match.
It supports fourteen comparison operators, AND/OR logical chaining, and
dot-notation field resolution including numeric array indices (e.g.
`channels.0.type`). It is intentionally storage-agnostic — contact data is
supplied by an injected `ContactDataProvider`, so the evaluator runs equally
well against an in-memory test array, a paginated database cursor, or a
remote API.
## Features
- 14 operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`,
`not_contains`, `in`, `not_in`, `exists`, `not_exists`, `starts_with`,
`ends_with`.
- AND/OR logical chaining via `logicalOp` on each subsequent rule (default
`"and"`; first rule's `logicalOp` is ignored).
- Dot-notation field resolution with numeric array index access.
- Empty-rules segment matches every contact.
- `evaluateRule` exposed for ad-hoc rule testing against a single contact.
- Unknown operator raises `AudienceRulesError` with
`AUDIENCE_RULES.UNKNOWN_OPERATOR` — the discriminated `SegmentOperator`
union makes this branch reachable only via a deliberate cast.
## Usage: Basic Example
```ts
import { SegmentEvaluator } from "@suaveplan/audience-rules";
const provider = async () => [
{ id: "c1", tags: ["vip"], status: "active" },
{ id: "c2", tags: ["basic"], status: "active" },
];
const evaluator = new SegmentEvaluator(provider);
const result = await evaluator.evaluate({
id: "seg1",
tenantId: "t1",
name: "VIPs",
rules: [{ field: "tags", operator: "contains", value: "vip" }],
refreshMode: "manual",
createdAt: new Date(),
updatedAt: new Date(),
});
// result.contactIds === ["c1"]
```
## Usage: Advanced Example
```ts
import { SegmentEvaluator } from "@suaveplan/audience-rules";
const provider = async () => [
{
id: "c1",
status: "active",
metadata: { country: "US", score: 90 },
channels: [{ type: "email", value: "a@b.com" }],
},
];
const evaluator = new SegmentEvaluator(provider);
// "active AND US, OR score >= 95"
const result = await evaluator.evaluate({
id: "seg",
tenantId: "t1",
name: "complex",
refreshMode: "manual",
rules: [
{ field: "status", operator: "eq", value: "active" },
{
field: "metadata.country",
operator: "eq",
value: "US",
logicalOp: "and",
},
{
field: "metadata.score",
operator: "gte",
value: 95,
logicalOp: "or",
},
],
createdAt: new Date(),
updatedAt: new Date(),
});
```
## API Reference
- `type ContactDataProvider = (tenantId: string) => Promise<readonly ContactData[]>`
- `class SegmentEvaluator` — constructor `(provider: ContactDataProvider)`.
- `evaluator.evaluate(segment: Segment): Promise<SegmentEvaluationResult>` —
fetches contacts via the provider, evaluates every rule against every
contact, returns matching IDs + `total` + `evaluatedAt` + `matchRate`.
- `evaluator.evaluateRule(contact: ContactData, rule: SegmentRule): boolean`
— pure helper; resolves the dot-notation field and applies the operator.
- Throws `AudienceRulesError` with `AUDIENCE_RULES.UNKNOWN_OPERATOR` when an
operator outside the closed `SegmentOperator` union is supplied.
## Implementation Notes
- `resolveField` returns `undefined` for any missing intermediate path
segment, so `not_exists` correctly matches deeply-nested missing fields.
- Comparison operators apply unchecked casts (`fieldValue as number`) — this
matches the upstream behaviour and lets callers compare ISO date strings
lexicographically, which is what the predicate helpers `lastActivityBefore`
/ `lastActivityAfter` rely on.
- `matchesRules` is left-to-right and does not short-circuit aggressively;
it always evaluates every rule. This keeps the algorithm simple and
deterministic; for very large rule lists the cost is `O(rules * contacts)`.
- The evaluator is stateless past the injected provider, so the same instance
can serve concurrent segment evaluations safely.

View File

@@ -0,0 +1,103 @@
# segment-refresher
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`segment-refresher.ts` orchestrates re-evaluation of one or many segments
and persists the resulting `contactIds` back to the `SegmentStore` cache. It
exposes a typed `EventBus<SegmentRefresherEventMap>` so downstream consumers
(broadcast schedulers, real-time dashboards, OTel exporters) can subscribe to
`segment:refreshed` without coupling to the refresher's internals. The
refresher is intentionally driver-agnostic — it does not own a timer; the
consumer drives `refresh` / `refreshAll` from whatever cadence policy
(cron, push, manual button) it prefers.
## Features
- `refresh(segmentId)` — single-segment re-evaluation; returns `undefined`
for an unknown ID without emitting.
- `refreshAll(tenantId)` — multi-segment fan-out; emits once per refreshed
segment.
- Typed `EventBus<SegmentRefresherEventMap>` — `events.on("segment:refreshed",
handler)` receives `{ segmentId, result }`.
- Cache write through `store.updateCache(segmentId, contactIds)` on every
refresh, updating `cachedContactIds`, `cachedCount`, `lastRefreshedAt`,
`updatedAt`.
- Pure composition of `SegmentEvaluator` + `SegmentStore` — both are
constructor-injected so test doubles wire in cleanly.
## Usage: Basic Example
```ts
import {
InMemorySegmentStore,
SegmentEvaluator,
SegmentRefresher,
} from "@suaveplan/audience-rules";
const store = new InMemorySegmentStore();
const evaluator = new SegmentEvaluator(async () => [
{ id: "c1", tags: ["vip"] },
]);
const refresher = new SegmentRefresher(store, evaluator);
const segment = await store.create({
tenantId: "t1",
name: "VIPs",
rules: [{ field: "tags", operator: "contains", value: "vip" }],
refreshMode: "manual",
});
const result = await refresher.refresh(segment.id);
```
## Usage: Advanced Example
```ts
import {
InMemorySegmentStore,
SegmentEvaluator,
SegmentRefresher,
} from "@suaveplan/audience-rules";
const refresher = new SegmentRefresher(
new InMemorySegmentStore(),
new SegmentEvaluator(async () => [])
);
refresher.events.on("segment:refreshed", ({ segmentId, result }) => {
// Forward to OTel, dashboard, downstream pub/sub, etc.
console.log(
`refreshed ${segmentId}: ${result.total} (${(result.matchRate * 100).toFixed(1)}%)`
);
});
await refresher.refreshAll("tenant-1");
```
## API Reference
- `interface SegmentRefresherEventMap` — `"segment:refreshed": { segmentId, result }`.
- `class SegmentRefresher` — constructor `(store: SegmentStore, evaluator: SegmentEvaluator)`.
- `refresher.events: EventBus<SegmentRefresherEventMap>` — typed event bus
from `@suaveplan/events`; `on` / `off` / `emit` / `use` / `clear`.
- `refresher.refresh(segmentId: string): Promise<SegmentEvaluationResult | undefined>`
- `refresher.refreshAll(tenantId: string): Promise<Map<string, SegmentEvaluationResult>>`
## Implementation Notes
- The migration swaps upstream's untyped `EventEmitter` for the suaveplan
`EventBus<TMap>`. The consumer API (`on("segment:refreshed", handler)`)
is preserved verbatim; only the type signature of `handler` becomes
precise.
- `refresh` returns `undefined` for a missing segment because the absence
is a normal (not exceptional) outcome — the consumer typically retries
with a corrected ID or surfaces a `404` at the API layer.
- `refreshAll` iterates sequentially; concurrent fan-out is intentionally
left to the caller (Promise.all over selected segment IDs) so the
refresher does not need to own a concurrency policy.
- The `events` field is publicly readable but the `EventBus` is mutable —
consumers are expected to subscribe, not replace.

View File

@@ -0,0 +1,93 @@
# store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`store.ts` defines the `SegmentStore` interface that abstracts segment
persistence behind a six-method contract (`create`, `getById`, `update`,
`delete`, `list`, `updateCache`) and ships `InMemorySegmentStore` — a
single-process, map-backed implementation suitable for development, tests,
and lightweight production deployments. The in-memory store is built around
injectable `Clock` and `IdGenerator` primitives from `@suaveplan/types`, so
production paths never call `Date.now()` / `new Date()` directly or mutate a
module-scope counter.
## Features
- `SegmentStore` interface — six async methods, all returning the persisted
`Segment` (or `undefined` when the identifier does not exist).
- `InMemorySegmentStore` — single-process map-backed implementation.
- `InMemorySegmentStoreOptions { clock?, idGenerator? }` — both injection
points are optional with sensible defaults.
- Zod boundary on `create` and `update` — invalid input raises
`AudienceRulesError` with code `AUDIENCE_RULES.INVALID_INPUT` (never a raw
`ZodError`).
- Multi-tenant isolation on `list(tenantId)` — segments owned by other
tenants are invisible.
- `updateCache(id, contactIds)` updates `cachedContactIds`, `cachedCount`,
`lastRefreshedAt`, and `updatedAt` atomically.
## Usage: Basic Example
```ts
import { InMemorySegmentStore } from "@suaveplan/audience-rules";
const store = new InMemorySegmentStore();
const segment = await store.create({
tenantId: "tenant-1",
name: "Active VIPs",
rules: [{ field: "tags", operator: "contains", value: "vip" }],
refreshMode: "manual",
});
```
## Usage: Advanced Example
```ts
import { InMemorySegmentStore } from "@suaveplan/audience-rules";
// Deterministic store for tests: pinned clock + monotonic ID generator
let n = 0;
const store = new InMemorySegmentStore({
clock: () => 1_700_000_000_000,
idGenerator: () => {
n += 1;
return `test_seg_${n}`;
},
});
const a = await store.create({
tenantId: "t1",
name: "A",
rules: [],
refreshMode: "manual",
});
// a.id === "test_seg_1"; a.createdAt.getTime() === 1_700_000_000_000
```
## API Reference
- `interface SegmentStore` — see Features.
- `interface InMemorySegmentStoreOptions` — `clock?: Clock`,
`idGenerator?: IdGenerator` (both from `@suaveplan/types`).
- `class InMemorySegmentStore implements SegmentStore` — constructor accepts
`InMemorySegmentStoreOptions`. Throws `AudienceRulesError` with
`AUDIENCE_RULES.INVALID_INPUT` on Zod failure in `create` / `update`, and
on `list` with an empty `tenantId`.
## Implementation Notes
- The default `IdGenerator` is a per-instance closure, so two separate stores
in the same process produce independent ID sequences. This matches the
`@suaveplan/contacts` `InMemoryContactStore` convention.
- `create` and `update` validate input with `parseOrThrow` before mutating
the internal map — failure leaves the store untouched.
- `list` takes an `O(n)` scan; the package is a development-tier store and
multi-tenant filtering happens in-memory. Production stores backed by a
database can index on `tenantId` directly.
- Every mutation updates `updatedAt` via the injected clock, so OTel
histograms and snapshot diffs in consumers stay deterministic under
`FakeClock`.

View File

@@ -0,0 +1,105 @@
# segment.types
## Purpose
`segment.types.ts` defines every shared type, interface, and closed union
consumed by `@suaveplan/audience-rules`. Centralising the data shapes in one
module gives the package a single auditable contract for the segmentation
domain — `Segment`, `SegmentRule`, the `SegmentOperator` and `LogicalOp`
discriminated unions, `RefreshMode`, the `SegmentEvaluationResult` carried
back from the evaluator, the `ContactData` shape consumed by it, and the
`CreateSegmentInput` / `UpdateSegmentInput` boundary types that the store's
Zod schemas validate against.
## Features
- `SegmentOperator` — closed 14-member union of comparison operators.
- `LogicalOp` — closed `"and" | "or"` union.
- `RefreshMode` — closed `"realtime" | "scheduled" | "manual"` union.
- `SegmentRule` interface — `field`, `operator`, optional `value` and
`logicalOp`. Optional members are typed `T | undefined` to satisfy
`exactOptionalPropertyTypes` against Zod-inferred outputs.
- `Segment` interface — full persisted shape with optional cache fields
(`cachedContactIds`, `cachedCount`, `lastRefreshedAt`).
- `CreateSegmentInput` / `UpdateSegmentInput` — boundary types matching the
Zod schemas in `schemas/schemas.ts`.
- `SegmentEvaluationResult` — return shape from `SegmentEvaluator.evaluate`.
- `ContactData` + `ContactDataChannel` + `ContactDataDelivery` — contact
shape consumed by the evaluator's `ContactDataProvider`.
## Usage: Basic Example
```ts
import type {
Segment,
SegmentRule,
} from "@suaveplan/audience-rules";
const rule: SegmentRule = {
field: "status",
operator: "eq",
value: "active",
};
```
## Usage: Advanced Example
```ts
import type {
ContactData,
LogicalOp,
Segment,
SegmentEvaluationResult,
} from "@suaveplan/audience-rules";
function combine(
a: SegmentEvaluationResult,
b: SegmentEvaluationResult,
op: LogicalOp
): readonly string[] {
if (op === "or") {
return Array.from(new Set([...a.contactIds, ...b.contactIds]));
}
const bSet = new Set(b.contactIds);
return a.contactIds.filter((id) => bSet.has(id));
}
const contact: ContactData = {
id: "c1",
tags: ["vip"],
metadata: { country: "US" },
};
```
## API Reference
- `type SegmentOperator` — `"eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "contains" | "not_contains" | "in" | "not_in" | "exists" | "not_exists" | "starts_with" | "ends_with"`.
- `type LogicalOp` — `"and" | "or"`.
- `type RefreshMode` — `"realtime" | "scheduled" | "manual"`.
- `interface SegmentRule` — see Features.
- `interface Segment` — see Features.
- `interface CreateSegmentInput` — `tenantId`, `name`, `description?`,
`rules`, `refreshMode`.
- `interface UpdateSegmentInput` — every field optional; the
corresponding Zod schema rejects an empty patch.
- `interface SegmentEvaluationResult` — `contactIds`, `total`,
`evaluatedAt`, `matchRate`.
- `interface ContactData` — `id`, optional `tags`, `channels`,
`status`, `metadata`, `lastActivityAt`, `deliveryHistory`.
- `interface ContactDataChannel` — `type`, `value`.
- `interface ContactDataDelivery` — `date`, `channel`, `status`.
## Implementation Notes
- Every optional property is explicitly typed `T | undefined` rather than
using just `?:` because the suaveplan tsconfig sets
`exactOptionalPropertyTypes: true`. This lets the Zod-inferred outputs
(which always include `undefined` in optional unions) assign cleanly to
these interfaces without intermediate casts.
- `ContactData.metadata` is `Readonly<Record<string, unknown>>` so the
evaluator's `resolveField` can walk nested metadata while keeping the
surface immutable for callers.
- Adding a new `SegmentOperator` is a breaking change — it widens the
closed union and forces consumers' switch tables to update. The
preferred extension path is a sibling predicate factory, not a new
operator.

View File

@@ -0,0 +1,120 @@
# @suaveplan/contacts - Universal contact and group management: hierarchical n-level groups (adjacency + materialized path), channel-agnostic contacts with dedup, many-to-many memberships with ancestor count propagation, audience resolution with descendant walk + dedup + filters, contact merger, CSV/JSON import-export, injectable Clock + IdGenerator, Zod input boundaries, six OTel counters and three histograms, and typed ContactsError hierarchy with stable CONTACTS.* code registry.
Universal contact and group management with hierarchical nesting, audience resolution, contact merger, and CSV/JSON import-export. Multi-tenant, channel-agnostic, deterministic (injectable `Clock` + `IdGenerator`), Zod-validated at every public boundary, OTel-instrumented via `@suaveplan/opentelemetry`, and ships a typed `ContactsError` hierarchy with a stable `CONTACTS.*` code registry.
<!-- begin:auto-docs -->
## Documentation tree
- [audience-resolver](./src/core/audience-resolver/audience-resolver.md) — `AudienceResolver` flattens a set of groups into a deduplicated, filtered contact list — the typical "send this campaig…
- [audience-resolver](./src/core/audience-resolver/audience-resolver.md) — `AudienceResolver` flattens a set of groups into a deduplicated, filtered contact list — the typical "send this campaig…
- [contact-manager](./src/core/contact-manager/contact-manager.md) — `ContactManager` is the public CRUD surface for contacts. It validates inputs against the corresponding Zod schemas (`c…
- [contact-manager](./src/core/contact-manager/contact-manager.md) — `ContactManager` is the public CRUD surface for contacts. It validates inputs against the corresponding Zod schemas (`c…
- [contact-merger](./src/core/contact-merger/contact-merger.md) — `ContactMerger` consolidates duplicate contacts into a single primary record. It merges channels (dedup-by-`type:value`…
- [contact-merger](./src/core/contact-merger/contact-merger.md) — `ContactMerger` consolidates duplicate contacts into a single primary record. It merges channels (dedup-by-`type:value`…
- [group-manager](./src/core/group-manager/group-manager.md) — `GroupManager` is the public surface for hierarchical groups. It manages n-level nesting via a hybrid adjacency-list +…
- [group-manager](./src/core/group-manager/group-manager.md) — `GroupManager` is the public surface for hierarchical groups. It manages n-level nesting via a hybrid adjacency-list +…
- [membership-manager](./src/core/membership-manager/membership-manager.md) — `MembershipManager` orchestrates many-to-many contact-group memberships. It owns the integrity guarantee that `Group.co…
- [membership-manager](./src/core/membership-manager/membership-manager.md) — `MembershipManager` orchestrates many-to-many contact-group memberships. It owns the integrity guarantee that `Group.co…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed `ContactsError` class for `@suaveplan/contacts`. Every failure path th…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed `ContactsError` class for `@suaveplan/contacts`. Every failure path th…
- [contact-exporter](./src/import-export/contact-exporter/contact-exporter.md) — `ContactExporter` serializes contacts to JSON or CSV with optional field inclusion flags. CSV output follows RFC 4180 q…
- [contact-exporter](./src/import-export/contact-exporter/contact-exporter.md) — `ContactExporter` serializes contacts to JSON or CSV with optional field inclusion flags. CSV output follows RFC 4180 q…
- [contact-importer](./src/import-export/contact-importer/contact-importer.md) — `ContactImporter` ingests contacts from already-parsed record arrays or raw CSV content. It applies caller-supplied fie…
- [contact-importer](./src/import-export/contact-importer/contact-importer.md) — `ContactImporter` ingests contacts from already-parsed record arrays or raw CSV content. It applies caller-supplied fie…
- [otel](./src/otel/instruments.md) — Exposes the bundle of OpenTelemetry metric instruments emitted by `@suaveplan/contacts`. The bundle binds lazily agains…
- [instruments](./src/otel/instruments.md) — Exposes the bundle of OpenTelemetry metric instruments emitted by `@suaveplan/contacts`. The bundle binds lazily agains…
- [schemas](./src/schemas/schemas.md) — Zod schemas for every public input boundary of `@suaveplan/contacts` plus the `parseOrThrow` helper that converts Zod v…
- [schemas](./src/schemas/schemas.md) — Zod schemas for every public input boundary of `@suaveplan/contacts` plus the `parseOrThrow` helper that converts Zod v…
- [contact-store](./src/stores/contact-store/contact-store.md) — Defines the `ContactStore` storage interface that `ContactManager` and `AudienceResolver` consume, plus the `InMemoryCo…
- [contact-store](./src/stores/contact-store/contact-store.md) — Defines the `ContactStore` storage interface that `ContactManager` and `AudienceResolver` consume, plus the `InMemoryCo…
- [group-store](./src/stores/group-store/group-store.md) — Defines the `GroupStore` storage interface that `GroupManager` and `AudienceResolver` consume, plus the `InMemoryGroupS…
- [group-store](./src/stores/group-store/group-store.md) — Defines the `GroupStore` storage interface that `GroupManager` and `AudienceResolver` consume, plus the `InMemoryGroupS…
- [membership-store](./src/stores/membership-store/membership-store.md) — Defines the `MembershipStore` storage interface that `MembershipManager`, `ContactMerger`, and `AudienceResolver` consu…
- [membership-store](./src/stores/membership-store/membership-store.md) — Defines the `MembershipStore` storage interface that `MembershipManager`, `ContactMerger`, and `AudienceResolver` consu…
- [tree](./src/tree/tree-utils.md) — Pure tree utilities for building, flattening, traversing, and inspecting `GroupTreeNode<TMeta>` hierarchies. None of th…
- [tree-utils](./src/tree/tree-utils.md) — Pure tree utilities for building, flattening, traversing, and inspecting `GroupTreeNode<TMeta>` hierarchies. None of th…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/contacts
```
## Quick start
```ts
import {
AudienceResolver,
ContactManager,
GroupManager,
InMemoryContactStore,
InMemoryGroupStore,
InMemoryMembershipStore,
MembershipManager,
} from "@suaveplan/contacts";
const contactStore = new InMemoryContactStore();
const groupStore = new InMemoryGroupStore();
const membershipStore = new InMemoryMembershipStore();
const contacts = new ContactManager({ store: contactStore });
const groups = new GroupManager({ store: groupStore });
const memberships = new MembershipManager({
contactStore,
groupStore,
membershipStore,
});
const resolver = new AudienceResolver({
contactStore,
groupStore,
membershipStore,
});
const vip = await groups.create({ tenantId: "acme", name: "VIP Customers" });
const alice = await contacts.create({
tenantId: "acme",
displayName: "Alice",
channels: [
{ type: "email", value: "alice@example.com", primary: true, verified: true },
],
});
await memberships.addContactToGroup(vip.id, alice.id);
const audience = await resolver.resolve({ groupIds: [vip.id] });
console.log(audience.totalCount); // 1
```
## Architecture
```text
ContactStore GroupStore MembershipStore <- pluggable storage
│ │ │
└─── ContactManager / GroupManager / MembershipManager <- CRUD + validation + OTel
│
┌─────────────┴─────────────┐
│ │
AudienceResolver ContactMerger / ContactImporter / ContactExporter
(group walk + dedup) (data ops + import/export pipelines)
```
Every public mutating method validates via Zod, raises a typed `ContactsError` (never a bare `Error`), and emits on at least one OTel instrument. All temporal and id-generating paths accept injectable `Clock` and `IdGenerator` from `@suaveplan/types`.
## Modules
| Module | Documentation |
|---|---|
| Error codes (`CONTACTS_CODES`, `ContactsError`) | [src/error-codes/error-codes.md](src/error-codes/error-codes.md) |
| OTel instruments (`getContactsInstruments`) | [src/otel/instruments.md](src/otel/instruments.md) |
| Zod schemas (`createContactInputSchema`, …, `parseOrThrow`) | [src/schemas/schemas.md](src/schemas/schemas.md) |
| Contact store (`InMemoryContactStore`) | [src/stores/contact-store/contact-store.md](src/stores/contact-store/contact-store.md) |
| Group store (`InMemoryGroupStore`) | [src/stores/group-store/group-store.md](src/stores/group-store/group-store.md) |
| Membership store (`InMemoryMembershipStore`) | [src/stores/membership-store/membership-store.md](src/stores/membership-store/membership-store.md) |
| `ContactManager` | [src/core/contact-manager/contact-manager.md](src/core/contact-manager/contact-manager.md) |
| `GroupManager` | [src/core/group-manager/group-manager.md](src/core/group-manager/group-manager.md) |
| `MembershipManager` | [src/core/membership-manager/membership-manager.md](src/core/membership-manager/membership-manager.md) |
| `ContactMerger` | [src/core/contact-merger/contact-merger.md](src/core/contact-merger/contact-merger.md) |
| `AudienceResolver` | [src/core/audience-resolver/audience-resolver.md](src/core/audience-resolver/audience-resolver.md) |
| `ContactExporter` | [src/import-export/contact-exporter/contact-exporter.md](src/import-export/contact-exporter/contact-exporter.md) |
| `ContactImporter` | [src/import-export/contact-importer/contact-importer.md](src/import-export/contact-importer/contact-importer.md) |
| Tree utilities (`buildGroupTree`, …) | [src/tree/tree-utils.md](src/tree/tree-utils.md) |

View File

@@ -0,0 +1,83 @@
# audience-resolver
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AudienceResolver` flattens a set of groups into a deduplicated, filtered contact list — the typical "send this campaign to everyone in VIP + Recent Purchasers minus Unsubscribed" use case. It recursively expands every input group via `GroupStore.getDescendants`, applies symmetrical exclusion expansion, looks up memberships in bulk, dedups by contact id while counting duplicates, filters by `channelType` and `statusFilter`, and reports `{ contacts, totalCount, groupsResolved, duplicatesRemoved }` so a UI can show the user exactly what was resolved and why. Resolution time is captured on the `audience.resolve_duration_ms` OTel histogram.
## Features
- Recursive group expansion via materialized path (`getDescendants`).
- Symmetric `excludeGroupIds` expansion — every descendant of every exclude is also excluded.
- `excludeContactIds` for individual opt-outs.
- `channelType` filter for "email-only" / "sms-only" sends.
- `statusFilter` for keeping only `active` (etc.) contacts.
- Dedup with `duplicatesRemoved` count surfaced in the result.
- `previewCount(options)` convenience returning just `totalCount`.
- Zod-validated options; `INVALID_INPUT` on malformed inputs.
- `audience.resolve_duration_ms` OTel histogram around every `resolve` call.
## Usage: Basic
```ts
import {
AudienceResolver,
InMemoryContactStore,
InMemoryGroupStore,
InMemoryMembershipStore,
} from "@suaveplan/contacts";
const resolver = new AudienceResolver({
contactStore: new InMemoryContactStore(),
groupStore: new InMemoryGroupStore(),
membershipStore: new InMemoryMembershipStore(),
});
const audience = await resolver.resolve({ groupIds: ["g_vip"] });
console.log(`${audience.totalCount} contacts`);
```
## Usage: Advanced
```ts
import { AudienceResolver } from "@suaveplan/contacts";
async function emailAudience(resolver: AudienceResolver) {
const result = await resolver.resolve({
groupIds: ["g_all-customers"],
excludeGroupIds: ["g_unsubscribed"],
excludeContactIds: ["c_blacklist-1", "c_blacklist-2"],
channelType: "email",
statusFilter: ["active"],
});
return result.contacts;
}
// Preview-only path for UI count display before send
async function showCount(resolver: AudienceResolver, groupIds: readonly string[]) {
const n = await resolver.previewCount({ groupIds });
return `Will send to ${n} people.`;
}
```
## API Reference
### `class AudienceResolver`
Constructor: `new AudienceResolver({ contactStore, groupStore, membershipStore, clock? })`.
- `resolve(options): Promise<AudienceResult>` — validates options, expands include + exclude groups, looks up memberships, dedups, filters, returns `{ contacts, totalCount, groupsResolved, duplicatesRemoved }`. Records duration on `audience.resolve_duration_ms`.
- `previewCount(options): Promise<number>` — convenience wrapper over `resolve` returning only `totalCount`.
## Implementation Notes
Group expansion uses `Set<string>` for both include and exclude sets, which makes the symmetric difference (`include − exclude`) O(N + M). Each `getDescendants` call is delegated to the store, so the cost depends on the store implementation — the in-memory store is O(N) per call; a database adapter should index `path` for `LIKE 'parent/%'` scans.
Dedup counts every encounter of a contact id beyond the first, so the count corresponds to "people who would have been emailed twice if we didn't dedup." This is the metric typical campaign UIs want to show.
`channelType` and `statusFilter` are applied after contact materialization, not at the store level. This is intentional — they can't be expressed cleanly in the bulk-membership lookup, and the contact slice is typically small enough by that point (post-group-expansion + dedup) that a JS filter is cheap. Database-backed stores wanting to push the filter down can override `contactStore.findByIds` to accept additional filter arguments.
`AudienceResolveOptions.deduplicate` is accepted for forward compatibility but does not change behavior today — the resolver always dedupes. A future version may make it opt-out for callers who want raw membership scans.

View File

@@ -0,0 +1,99 @@
# contact-manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ContactManager` is the public CRUD surface for contacts. It validates inputs against the corresponding Zod schemas (`createContactInputSchema`, `updateContactInputSchema`) before any side effect, raises typed `ContactsError` codes on every failure path, optionally deduplicates by channel (default on) to prevent two contacts from owning the same `(type, value)` in a tenant, and emits OTel counters (`contacts.created_total`, `contacts.updated_total`, `contacts.deleted_total`) on every mutation. It composes around any `ContactStore` so it works with the in-memory reference store, a database adapter, or a custom backend.
## Features
- Zod-validated boundaries — `INVALID_INPUT` raised before any side effect on bad input.
- Channel deduplication — `CONTACT_CHANNEL_EXISTS` on create-collision, `CONTACT_CHANNEL_EXISTS_ON_OTHER` on update / addChannel collision; configurable.
- Primary channel auto-promotion — the first channel is marked primary when none are flagged.
- Tag operations — `addTag` (idempotent), `removeTag`.
- Channel operations — `addChannel` honors the same dedup rules as `create` / `update`.
- OTel counters with `tenant` attribute on every mutating method.
- All read paths (`getById`, `findByChannel`, `findByIds`, `list`) pass through to the store.
## Usage: Basic
```ts
import {
ContactManager,
InMemoryContactStore,
} from "@suaveplan/contacts";
const mgr = new ContactManager({ store: new InMemoryContactStore() });
const alice = await mgr.create({
tenantId: "acme",
displayName: "Alice",
channels: [
{ type: "email", value: "alice@x.io", primary: true, verified: false },
],
});
await mgr.addTag(alice.id, "vip");
```
## Usage: Advanced
```ts
import {
CONTACTS_CODES,
ContactManager,
ContactsError,
InMemoryContactStore,
} from "@suaveplan/contacts";
const mgr = new ContactManager({
store: new InMemoryContactStore(),
deduplicateByChannel: true,
});
try {
await mgr.create({
tenantId: "acme",
displayName: "Bob",
channels: [
{ type: "email", value: "alice@x.io", primary: true, verified: false },
],
});
} catch (err) {
if (
err instanceof ContactsError &&
err.code === CONTACTS_CODES.CONTACT_CHANNEL_EXISTS
) {
// Offer to merge with err.contactId
} else {
throw err;
}
}
```
## API Reference
### `class ContactManager`
Constructor: `new ContactManager({ store, deduplicateByChannel? = true })`.
- `create(input)` — validates, dedups, auto-promotes primary, emits `contacts.created_total`. Returns the persisted `Contact`.
- `getById(id)` — returns `Contact | null`.
- `update(id, input)` — validates, raises `CONTACT_NOT_FOUND` if missing, dedups when channels are in the patch, emits `contacts.updated_total`.
- `delete(id)` — idempotent, emits `contacts.deleted_total` only when a contact was actually present.
- `list(query)` — paginated read passing through to the store.
- `findByChannel(tenantId, type, value)` / `findByIds(ids)` — straight passes.
- `addTag(id, tag)` — idempotent; no-op if tag already present.
- `removeTag(id, tag)` — removes tag (no-op if absent).
- `addChannel(id, channel)` — appends; dedup-checked when enabled.
All async; all may raise `ContactsError`.
## Implementation Notes
`promotePrimary` is intentionally non-mutating — it returns a new array with the first channel marked primary when none are flagged. This keeps `CreateContactInput.channels` `readonly`-compatible.
`deduplicateByChannel: false` exists for two real use cases: bulk imports that already trust the source, and migrations where you're knowingly bringing in a temporary collision and will fix it in a follow-up step. The flag is configurable per `ContactManager` instance so a single store can host both strict and lenient managers.
OTel emission happens after a successful side effect, never before. Failed operations don't increment the success counters — this is important for dashboards that compute error rates from counter ratios.

View File

@@ -0,0 +1,92 @@
# contact-merger
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ContactMerger` consolidates duplicate contacts into a single primary record. It merges channels (dedup-by-`type:value`, secondaries demoted from primary), tags (set union), and metadata (primary wins on key collision), transfers every secondary's group memberships to the primary unless the primary is already a member, and then deletes the secondaries. The operation is atomic from the consumer's perspective (a single async call), uses an injected `Clock` for the timestamp on every transferred membership, raises typed `ContactsError` codes on every failure, and emits the `contacts.merged_total` OTel counter.
## Features
- Channel merge with primary-flag demotion of secondary channels.
- Tag union via `Set` semantics.
- Metadata merge with primary-priority key resolution.
- Membership transfer without duplicates (skip when primary is already a member).
- Secondary deletion with full membership cleanup.
- `MERGE_PRIMARY_NOT_FOUND` / `MERGE_SECONDARY_NOT_FOUND` typed errors.
- `contacts.merged_total{tenant, secondaries}` OTel counter.
- Injectable `Clock` for deterministic membership `addedAt`.
## Usage: Basic
```ts
import {
ContactMerger,
InMemoryContactStore,
InMemoryMembershipStore,
} from "@suaveplan/contacts";
const merger = new ContactMerger({
contactStore: new InMemoryContactStore(),
membershipStore: new InMemoryMembershipStore(),
});
const { mergedContact, removedContactIds } = await merger.merge(
"primary-id",
["secondary-1", "secondary-2"],
);
```
## Usage: Advanced
```ts
import {
CONTACTS_CODES,
ContactsError,
ContactMerger,
} from "@suaveplan/contacts";
async function safeMerge(
merger: ContactMerger,
primaryId: string,
secondaryIds: readonly string[],
) {
try {
return await merger.merge(primaryId, secondaryIds);
} catch (err) {
if (err instanceof ContactsError) {
switch (err.code) {
case CONTACTS_CODES.MERGE_PRIMARY_NOT_FOUND:
return { error: "primary deleted by another session", err };
case CONTACTS_CODES.MERGE_SECONDARY_NOT_FOUND:
return { error: "one or more secondaries are missing", err };
default:
throw err;
}
}
throw err;
}
}
```
## API Reference
### `class ContactMerger`
Constructor: `new ContactMerger({ contactStore, membershipStore, clock? })`.
- `merge(primaryId, secondaryIds): Promise<MergeResult>` — validates primary + every secondary exists, merges channels / tags / metadata into the primary via `contactStore.update`, transfers secondary memberships, removes all secondary memberships, deletes every secondary, emits `contacts.merged_total{tenant, secondaries: <count>}`. Returns `{ mergedContact, removedContactIds }`.
`MergeResult.mergedContact` is the post-update primary. `MergeResult.removedContactIds` is the list of secondary ids that were deleted.
## Implementation Notes
Channel merge dedup uses `${type}:${value}` as the canonical key. When a secondary contributes a channel that is already on the primary, the channel is dropped (the primary's flags win). When a secondary contributes a new channel, it is appended with `primary: false` regardless of the secondary's original flag — the primary's primary channel stays primary.
Metadata merge is shallow (`if (!(key in merged)) merged[key] = value`). Nested objects on the primary are not deep-merged with secondaries; consumers wanting deep merge should pre-process and pass a flat metadata bag.
Membership transfer happens before secondary deletion, in order: for each secondary, walk its memberships; skip when the primary is already in that group; otherwise insert a new membership for the primary with the merger's `Clock`-derived `addedAt`. Then `removeAllForContact(secondary.id)` removes the secondary's stale memberships, and finally `contactStore.delete(secondary.id)` removes the secondary.
The operation is not transactional at the store level — the in-memory implementation is single-threaded so the sequencing is effectively atomic, but a database-backed `ContactStore` should wrap the merger in a transaction to maintain the same guarantee.

View File

@@ -0,0 +1,100 @@
# group-manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GroupManager` is the public surface for hierarchical groups. It manages n-level nesting via a hybrid adjacency-list + materialized-path strategy, automatically derives `path` and `depth` from the parent on create, propagates path rewrites through descendants on rename or move, blocks circular references on move, and cascade-deletes descendants on delete. Every input is Zod-validated, every failure path raises a typed `ContactsError`, and every successful create emits the `groups.created_total` OTel counter.
## Features
- Hierarchical create — child `path` derives from parent, `depth = parent.depth + 1`.
- Rename propagation — changing a group's name rewrites the slug in its `path` and every descendant's `path`.
- Move with cycle detection — `GROUP_CIRCULAR_REF` blocks moving a group under one of its own descendants (or itself).
- Move with descendant rewrite — descendants follow the new path; `depth` adjusts by the depth delta.
- Cascade delete — `delete` removes the group and every descendant in DFS order.
- `GROUP_PARENT_NOT_FOUND` on create with missing parent; `GROUP_NEW_PARENT_NOT_FOUND` on move to missing parent.
- Injectable `Clock` for deterministic `createdAt` / `updatedAt`.
- Reads: `list`, `getChildren`, `getDescendants`, `getRoots` pass through to the store.
## Usage: Basic
```ts
import {
GroupManager,
InMemoryGroupStore,
} from "@suaveplan/contacts";
const mgr = new GroupManager({ store: new InMemoryGroupStore() });
const root = await mgr.create({ tenantId: "acme", name: "All Customers" });
const vip = await mgr.create({
tenantId: "acme",
name: "VIP",
parentId: root.id,
});
console.log(vip.path); // "/all-customers/vip"
console.log(vip.depth); // 1
```
## Usage: Advanced
```ts
import {
CONTACTS_CODES,
ContactsError,
GroupManager,
InMemoryGroupStore,
} from "@suaveplan/contacts";
const store = new InMemoryGroupStore();
const mgr = new GroupManager({ store, clock: () => 1_700_000_000_000 });
const a = await mgr.create({ tenantId: "t", name: "Region A" });
const b = await mgr.create({ tenantId: "t", name: "Region B" });
const child = await mgr.create({
tenantId: "t",
name: "Office",
parentId: a.id,
});
try {
await mgr.move(a.id, child.id); // would create a cycle
} catch (err) {
if (
err instanceof ContactsError &&
err.code === CONTACTS_CODES.GROUP_CIRCULAR_REF
) {
console.warn("cannot move parent under its own descendant");
}
}
// Move office under region B — descendants follow
await mgr.move(child.id, b.id);
```
## API Reference
### `class GroupManager`
Constructor: `new GroupManager({ store, clock? })`.
- `create(input)` — derives path + depth from parent; emits `groups.created_total`.
- `getById(id)` / `list(query)` / `getChildren(id)` / `getRoots(tenantId)` — straight passes.
- `getDescendants(id)` — returns `[]` if id is missing; otherwise descendants of the group's path.
- `update(id, input)` — patch; renaming rewrites path + descendants. Raises `INVALID_INPUT` / `GROUP_NOT_FOUND`.
- `delete(id)` — cascade-deletes descendants then the group itself. No-op for missing id.
- `move(id, newParentId)` — re-parents with cycle detection and descendant path rewrite. `newParentId === null` flattens to root. Raises `GROUP_NOT_FOUND` / `GROUP_CIRCULAR_REF` / `GROUP_NEW_PARENT_NOT_FOUND`.
All async; all may raise `ContactsError`.
## Implementation Notes
Slugification (`slugify(name)`) lowercases, replaces non-alphanumeric runs with `-`, and trims leading/trailing dashes. It's intentionally simple — i18n-aware slugging is the consumer's responsibility (e.g. via `@suaveplan/i18n` formatters before calling `create`).
Path rewrite is non-recursive: `updateDescendantPaths` fetches all descendants once via `store.getDescendants(oldPath)`, then issues parallel `store.update` calls keyed by id. The same primitive powers both rename propagation and move-with-descendants, with `depthDelta` defaulting to 0 for rename and computed for move.
Cycle detection uses descendant scanning rather than ancestor walking because `getDescendants` is already implemented as an O(N) path-prefix scan; doing an ancestor walk would require multiple `getById` round-trips. The trade-off is fine for typical hierarchies (tens to thousands of nodes per tenant).
`delete` does not currently emit a `groups.deleted_total` counter — only `create` is metered. This matches the upstream design and keeps the surface minimal; consumers wanting deletion metrics can wrap the manager.

View File

@@ -0,0 +1,95 @@
# membership-manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MembershipManager` orchestrates many-to-many contact-group memberships. It owns the integrity guarantee that `Group.contactCount` (direct) and `Group.totalContactCount` (recursive, ancestor-propagated) stay consistent with the actual membership records — every add/remove walks the ancestor chain and updates the rolled-up totals so list views can render hierarchical totals without an extra round-trip per group. It also exposes bulk helpers (`addContactToMultipleGroups`, `addMultipleContactsToGroup`) that swallow `MEMBERSHIP_EXISTS` collisions and continue, which is the convenient default for batch imports.
## Features
- Single-pair add / remove with full ancestor count propagation.
- Bulk helpers that swallow `MEMBERSHIP_EXISTS` but rethrow every other error.
- Existence + lookup helpers (`isContactInGroup`, `getGroupsForContact`, `getMembersOfGroup`, `countMembersInGroup`).
- Cascade helper (`removeAllMembershipsForContact`) used by `ContactMerger` and by GDPR-delete consumers.
- Typed `ContactsError` codes (`MEMBERSHIP_GROUP_NOT_FOUND`, `MEMBERSHIP_CONTACT_NOT_FOUND`, `MEMBERSHIP_EXISTS`) on every failure.
- Injectable `Clock` for deterministic `addedAt` timestamps.
- `memberships.added_total` OTel counter with `tenant` attribute.
## Usage: Basic
```ts
import {
InMemoryContactStore,
InMemoryGroupStore,
InMemoryMembershipStore,
MembershipManager,
} from "@suaveplan/contacts";
const cs = new InMemoryContactStore();
const gs = new InMemoryGroupStore();
const ms = new InMemoryMembershipStore();
const mgr = new MembershipManager({
contactStore: cs,
groupStore: gs,
membershipStore: ms,
});
await mgr.addContactToGroup("g_1", "c_1");
```
## Usage: Advanced
```ts
import {
CONTACTS_CODES,
ContactsError,
MembershipManager,
} from "@suaveplan/contacts";
// Bulk add tolerating duplicates
await mgr.addMultipleContactsToGroup("g_vip", ["c_1", "c_2", "c_3"]);
// Bulk add to multiple groups for one contact (raises on first non-duplicate failure)
try {
await mgr.addContactToMultipleGroups("c_42", ["g_a", "g_b", "g_c"]);
} catch (err) {
if (
err instanceof ContactsError &&
err.code === CONTACTS_CODES.MEMBERSHIP_GROUP_NOT_FOUND
) {
console.warn(`group ${err.groupId} does not exist`);
} else {
throw err;
}
}
await mgr.removeAllMembershipsForContact("c_off-boarded");
```
## API Reference
### `class MembershipManager`
Constructor: `new MembershipManager({ contactStore, groupStore, membershipStore, clock? })`.
- `addContactToGroup(groupId, contactId, metadata?)` — validates both ids exist, raises `MEMBERSHIP_EXISTS` on duplicate, increments `contactCount` + every ancestor `totalContactCount`, emits `memberships.added_total`.
- `removeContactFromGroup(groupId, contactId)` — decrements (floor 0) and propagates negative delta up the chain.
- `addContactToMultipleGroups(contactId, groupIds, metadata?)` — strict; rethrows on any failure.
- `addMultipleContactsToGroup(groupId, contactIds, metadata?)` — lenient; swallows `MEMBERSHIP_EXISTS` and continues.
- `getGroupsForContact(contactId)` / `getMembersOfGroup(groupId)` — full membership records.
- `isContactInGroup(groupId, contactId)` — boolean lookup.
- `removeAllMembershipsForContact(contactId)` — cascade; calls `removeContactFromGroup` per membership to keep totals consistent.
- `countMembersInGroup(groupId)` — direct count, distinct from `Group.contactCount`.
All async; all may raise `ContactsError`.
## Implementation Notes
Ancestor count propagation walks `parentId` chains one step at a time via `groupStore.getById`. If the chain is broken (parent has been deleted out from under us), the loop terminates safely. The behavior is exercised by a dedicated test that constructs a dangling-parent state.
The "swallow duplicates" bulk add is intentional: typical use cases (import a CSV of 10,000 rows into a group; tag a customer with their five existing categories) routinely encounter duplicates and treating them as errors would force callers to pre-check every pair. The strict `addContactToGroup` and `addContactToMultipleGroups` are available for callers who want explicit validation.
`floor 0` on remove (`Math.max(0, contactCount - 1)`) prevents the count from going negative if the same contact is removed twice. This is defensive — the store-level `remove` is already idempotent — but the count math is not, so we clamp.

View File

@@ -0,0 +1,86 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry and typed `ContactsError` class for `@suaveplan/contacts`. Every failure path the package exposes raises a `ContactsError` carrying one of fourteen registered codes (`INVALID_INPUT`, `CONTACT_NOT_FOUND`, `CONTACT_CHANNEL_EXISTS`, `CONTACT_CHANNEL_EXISTS_ON_OTHER`, `GROUP_NOT_FOUND`, `GROUP_PARENT_NOT_FOUND`, `GROUP_CIRCULAR_REF`, `GROUP_NEW_PARENT_NOT_FOUND`, `MEMBERSHIP_EXISTS`, `MEMBERSHIP_GROUP_NOT_FOUND`, `MEMBERSHIP_CONTACT_NOT_FOUND`, `MERGE_PRIMARY_NOT_FOUND`, `MERGE_SECONDARY_NOT_FOUND`, `IMPORT_VALIDATION_FAILED`). Codes are namespaced strings (`"CONTACTS.GROUP_CIRCULAR_REF"`) so consumers can dispatch programmatically without parsing messages, and the typed error narrows the `contactId` / `groupId` / `tenantId` discriminants for diagnostics.
## Features
- `CONTACTS_CODES` — `defineCodes("CONTACTS", [...])` registry; the canonical source of truth for every code emitted by the package.
- `ContactsError extends SuaveplanError<ContactsErrorCode>` — typed error class. `instanceof` narrows `code` to a literal-string union.
- `ContactsErrorCode` — union of every registered code.
- `ContactsErrorOptions` — options bag extending `SuaveplanErrorOptions` with `contactId`, `groupId`, `tenantId` discriminants and standard `cause` chaining.
- Discriminants flow through to OTel span events via `SuaveplanError.toSpanEvent()` (inherited from base).
## Usage: Basic
```ts
import { CONTACTS_CODES, ContactsError } from "@suaveplan/contacts";
function requireContact(id: string) {
const c = lookup(id);
if (!c) {
throw new ContactsError(
CONTACTS_CODES.CONTACT_NOT_FOUND,
`Contact '${id}' not found`,
{ contactId: id },
);
}
return c;
}
```
## Usage: Advanced
```ts
import { CONTACTS_CODES, ContactsError } from "@suaveplan/contacts";
try {
await contacts.create(input);
} catch (err) {
if (err instanceof ContactsError) {
switch (err.code) {
case CONTACTS_CODES.INVALID_INPUT:
return showValidationToast(err.message);
case CONTACTS_CODES.CONTACT_CHANNEL_EXISTS:
return openMergeDialog(err.contactId);
default:
throw err;
}
}
throw err;
}
```
## API Reference
### `CONTACTS_CODES`
A frozen registry produced by `defineCodes("CONTACTS", [...] as const)`. Each entry maps a bare key (e.g. `MERGE_PRIMARY_NOT_FOUND`) to its namespaced string (`"CONTACTS.MERGE_PRIMARY_NOT_FOUND"`).
### `ContactsErrorCode`
Union type `(typeof CONTACTS_CODES)[keyof typeof CONTACTS_CODES]`. Use to type-check `error.code` in catch blocks.
### `ContactsError(code, message, options?)`
Constructor parameters:
- `code: ContactsErrorCode` — required, the discriminant.
- `message: string` — required, English diagnostic.
- `options.contactId?: string` — attaches `error.contactId`.
- `options.groupId?: string` — attaches `error.groupId`.
- `options.tenantId?: string` — attaches `error.tenantId`.
- `options.cause?: unknown` — standard ES `Error.cause`; preserved through serialization and span events.
## Implementation Notes
The discriminants live as direct properties on the instance rather than inside `options.context` so consumers can do `if (err.contactId)` without an extra unwrap. Codes are validated at registry time by `defineCodes`, which freezes the result and enforces unique namespace tokens.
Adding a new code requires three concurrent edits: append the literal to the `CONTACTS_CODES` array, raise it from at least one production call site, and add a test that round-trips an instance with the new code. The OpenSpec conformance script (`bun scripts/conformance.ts @suaveplan/contacts`) and the package's own `contacts.spec-conformance.test.ts` both enforce the "every code thrown" rule — removing a code to placate a gate is banned per `AGENTS.md` §2c.
Error-code strings are part of the public API. Renaming one is a breaking change. Add a new code instead and let consumers migrate explicitly.

View File

@@ -0,0 +1,78 @@
# contact-exporter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ContactExporter` serializes contacts to JSON or CSV with optional field inclusion flags. CSV output follows RFC 4180 quoting rules (cells containing commas, double quotes, or newlines are wrapped in double quotes; embedded quotes are doubled). The unified `export(contacts, options)` entry point validates options via `exportOptionsSchema` and emits the `contacts.export_duration_ms` OTel histogram so dashboards can track export performance against payload size.
## Features
- `exportToJson(contacts, options?)` — pretty-printed JSON array via `JSON.stringify(..., 2)`.
- `exportToCsv(contacts, options?)` — RFC 4180-compliant CSV with header row.
- `export(contacts, options)` — Zod-validated entry that dispatches to JSON or CSV.
- Field-inclusion flags — `includeChannels`, `includeTags`, `includeMetadata`.
- Primary channel resolution — exports the first channel of type `email` (primary) and the first of type `sms` or `phone` (primary).
- OTel histogram with `format` attribute on every `export` call.
- Injectable `Clock` for deterministic duration measurement.
## Usage: Basic
```ts
import { ContactExporter } from "@suaveplan/contacts";
const exporter = new ContactExporter();
const csv = exporter.export(contacts, { format: "csv" });
const json = exporter.export(contacts, { format: "json" });
```
## Usage: Advanced
```ts
import { ContactExporter } from "@suaveplan/contacts";
const exporter = new ContactExporter({ clock: () => Date.now() });
// Minimal CSV — no channels, no tags, no metadata
const minimal = exporter.exportToCsv(contacts, {
format: "csv",
includeChannels: false,
includeTags: false,
});
// Full JSON dump including metadata as a JSON string column
const full = exporter.exportToJson(contacts, {
format: "json",
includeMetadata: true,
});
await Deno.writeTextFile("contacts.csv", minimal);
```
## API Reference
### `class ContactExporter`
Constructor: `new ContactExporter({ clock? })`.
- `export(contacts, options): string` — validates `options` via `exportOptionsSchema`, dispatches to `exportToJson` or `exportToCsv`, records duration. Raises `ContactsError(INVALID_INPUT)` if options are malformed.
- `exportToJson(contacts, options?): string` — direct JSON entry; does not record duration.
- `exportToCsv(contacts, options?): string` — direct CSV entry; does not record duration; returns `""` when the input is empty.
### `interface ExportOptions`
- `format: "csv" | "json"` — required dispatcher.
- `fields?: readonly string[]` — reserved for future column-selection; ignored today.
- `includeChannels?: boolean` — defaults to `true`. When false, omits `email` and `phone` columns.
- `includeTags?: boolean` — defaults to `true`. When false, omits the `tags` column.
- `includeMetadata?: boolean` — defaults to `false`. When true, serializes metadata as a JSON string column.
## Implementation Notes
The CSV column set is fixed (`id, displayName, firstName, lastName, status` plus optional `email, phone, tags, metadata`). Customising the column projection is reserved for the `fields` option in a future version; the current API matches the upstream behavior so existing consumers don't need to migrate.
Phone resolution accepts both `sms` and `phone` channel types because legacy upstream data sometimes used `phone` for the same field SMS sends use. The primary-flag heuristic (`primary: true`) picks the most-recently-flagged channel rather than guessing — call sites are expected to maintain the flag correctly.
The duration histogram fires only from the top-level `export(...)` call. `exportToJson` and `exportToCsv` are intentionally instrument-free so they remain pure-functional when consumers want to bypass the dispatcher (and the OTel emission) for hot-path serialization.

View File

@@ -0,0 +1,110 @@
# contact-importer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ContactImporter` ingests contacts from already-parsed record arrays or raw CSV content. It applies caller-supplied field mappings (e.g. `Name → displayName`, `Email → email`), prepends default tags, optionally checks for channel-level duplicates before insert, and captures per-row exceptions into a structured `ImportResult.errors` array so a partial import never throws on the first failure. Total wall-clock duration is recorded on the `contacts.import_duration_ms` OTel histogram for capacity-planning dashboards.
## Features
- `importFromRecords(records, options)` — accepts pre-parsed `Record<string, string>[]`.
- `importFromCsv(csv, options)` — RFC 4180-style quoted-field parser, then forwards to `importFromRecords`.
- Field mapping — `displayName`, `firstName`, `lastName`, `email`, `phone`, `tags` plus open custom slots.
- `displayName` fallback — combines first + last name when no direct mapping is provided.
- `defaultTags` — prepended to every imported contact's tag list.
- `skipDuplicates` — checks every channel against `ContactStore.findByChannelValue` before insert.
- Per-row error capture — exceptions are recorded with 1-based row number and the offending record.
- OTel histogram `contacts.import_duration_ms{tenant}` on every import.
## Usage: Basic
```ts
import {
ContactImporter,
InMemoryContactStore,
} from "@suaveplan/contacts";
const store = new InMemoryContactStore();
const importer = new ContactImporter({ store });
const result = await importer.importFromCsv(
"Name,Email\nAlice,alice@x.io\nBob,bob@x.io",
{
tenantId: "acme",
fieldMapping: { displayName: "Name", email: "Email" },
},
);
console.log(result.imported); // 2
```
## Usage: Advanced
```ts
import { ContactImporter, InMemoryContactStore } from "@suaveplan/contacts";
const importer = new ContactImporter({ store: new InMemoryContactStore() });
const records = [
{ First: "Jane", Last: "Doe", Email: "jane@x.io", Tags: "vip,2026-cohort" },
{ First: "John", Last: "", Email: "john@x.io" },
];
const result = await importer.importFromRecords(records, {
tenantId: "acme",
fieldMapping: {
firstName: "First",
lastName: "Last",
email: "Email",
tags: "Tags",
},
defaultTags: ["imported-2026"],
skipDuplicates: true,
});
if (result.errors.length > 0) {
console.error(`failed ${result.errors.length} rows:`);
for (const e of result.errors) console.error(` row ${e.row}: ${e.message}`);
}
```
## API Reference
### `class ContactImporter`
Constructor: `new ContactImporter({ store, clock? })`.
- `importFromRecords(records, options): Promise<ImportResult>` — main entry. Validates options, iterates records, maps, dedups, inserts, captures errors. Records duration.
- `importFromCsv(csv, options): Promise<ImportResult>` — parses CSV, forwards to `importFromRecords`. Wraps non-`ContactsError` parse failures into `ContactsError(IMPORT_VALIDATION_FAILED)`.
### `interface FieldMapping`
Open-shape map from source column name to contact field. Named slots: `displayName`, `firstName`, `lastName`, `email`, `phone`, `tags`. Custom keys are tolerated and ignored by the mapper today (reserved for future custom-field metadata).
### `interface ImportOptions`
- `tenantId: string` — required.
- `fieldMapping: FieldMapping` — required.
- `defaultTags?: readonly string[]` — prepended to every contact's tags.
- `skipDuplicates?: boolean` — when true, channel collisions cause `skipped++` instead of an insert.
- `channelType?: string` — reserved for future per-import channel-type override.
### `interface ImportResult`
`{ imported: number, skipped: number, errors: readonly ImportError[] }`.
### `interface ImportError`
`{ row: number, message: string, data?: Record<string, string> }` with 1-based `row`.
## Implementation Notes
`displayName` resolution prefers the explicit `displayName` mapping when present and non-empty; otherwise it joins first + last with a space and trims. If both paths produce an empty string, the row is silently skipped (counted in `skipped`, not `errors`) — the assumption is that the row is intentionally blank and not a bug.
`skipDuplicates` walks every channel in the candidate input and queries `findByChannelValue` against the store — if any channel already exists, the entire row is skipped. This means a contact with two channels skips on the first match; the order is the input order.
The CSV parser is a minimal RFC 4180 implementation that handles quoted fields containing commas. It does not handle multi-line quoted fields (a quoted value spanning a literal newline) — consumers needing that should pre-parse with a richer CSV library and call `importFromRecords` directly.
Per-row error capture wraps every iteration in `try / catch` so one bad row never blocks the rest. Non-`Error` throwables are coerced via `String(err)` so the `ImportError.message` field is always populated.

View File

@@ -0,0 +1,86 @@
# otel/instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes the bundle of OpenTelemetry metric instruments emitted by `@suaveplan/contacts`. The bundle binds lazily against the current global `MeterProvider`, so callers can install a custom provider (e.g. an `InMemoryMetricExporter` in tests) before the first `getContactsInstruments()` call. `resetContactsInstruments()` drops the cache so a freshly-installed provider can take effect mid-process — essential for test isolation.
## Features
- Six counters: `contacts.created_total`, `contacts.updated_total`, `contacts.deleted_total`, `contacts.merged_total`, `groups.created_total`, `memberships.added_total`.
- Three histograms (unit `ms`): `contacts.import_duration_ms`, `contacts.export_duration_ms`, `audience.resolve_duration_ms`.
- Counter attributes: `tenant` on every contact / group operation, plus `secondaries` on the merge counter and `format` on the export histogram.
- Lazy, cached binding to the global `MeterProvider` for zero-cost no-op until first use.
- `resetContactsInstruments()` releases the cache so test setup can rebind after installing a fresh provider.
## Usage: Basic
```ts
import {
ContactManager,
getContactsInstruments,
InMemoryContactStore,
} from "@suaveplan/contacts";
const mgr = new ContactManager({ store: new InMemoryContactStore() });
await mgr.create({
tenantId: "acme",
displayName: "Alice",
channels: [
{ type: "email", value: "alice@x.io", primary: true, verified: false },
],
});
const instruments = getContactsInstruments();
console.log(instruments.contactsCreatedTotal); // OTel Counter handle
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import {
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { resetContactsInstruments } from "@suaveplan/contacts";
const exporter = new InMemoryMetricExporter(0);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetContactsInstruments();
// … run operations …
await provider.forceFlush();
console.log(exporter.getMetrics()); // ResourceMetrics[]
```
## API Reference
### `interface ContactsInstruments`
Read-only object exposing every Counter and Histogram. See Features for the full list. Counters: `contactsCreatedTotal`, `contactsUpdatedTotal`, `contactsDeletedTotal`, `contactsMergedTotal`, `groupsCreatedTotal`, `membershipsAddedTotal`. Histograms: `contactsImportDurationMs`, `contactsExportDurationMs`, `audienceResolveDurationMs`.
### `getContactsInstruments(): ContactsInstruments`
Returns the cached bundle, lazily creating it on first call against `metrics.getMeter("@suaveplan/contacts")`. Subsequent calls return the same reference (`===`).
### `resetContactsInstruments(): void`
Drops the cache. Required in test setup after `metrics.setGlobalMeterProvider(...)`, otherwise the cached instruments stay bound to the previous provider.
## Implementation Notes
Instrument names follow the OTel naming convention `<domain>.<metric>_total` (counters) and `<domain>.<metric>_duration_ms` (histograms). The duration histograms are populated by the production managers using a single `clock()` snapshot before and after the operation; that means duration is wall-clock against the injected `Clock`, which a `FakeClock` can advance deterministically.
The cache pattern (`let cached: ContactsInstruments | undefined`) is intentional: re-binding on every call would create new Counter instances per operation and fragment the export. The same pattern is used elsewhere in the project (`@suaveplan/api-plugin-event-bus`) and is the canonical shape for "lazy-bound, test-resettable" OTel surfaces.
All instruments are pure metrics — no spans are emitted from this package. Span correlation, if needed, is the responsibility of the caller via the active OTel context (e.g. an upstream Hono middleware).

View File

@@ -0,0 +1,93 @@
# schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas for every public input boundary of `@suaveplan/contacts` plus the `parseOrThrow` helper that converts Zod validation failures into a typed `ContactsError(INVALID_INPUT)`. Boundary validation is enforced inside the managers (`ContactManager.create`, `GroupManager.create`, etc.) so consumers never see a raw `ZodError` — they always see a stable, dispatchable code with a human-readable message listing the failing field paths.
## Features
- `createContactInputSchema`, `updateContactInputSchema`, `contactQuerySchema` — contact CRUD inputs.
- `createGroupInputSchema`, `updateGroupInputSchema`, `groupQuerySchema` — group CRUD inputs.
- `addMemberInputSchema`, `audienceResolveOptionsSchema` — membership and audience inputs.
- `exportOptionsSchema`, `importOptionsSchema` — import / export bag schemas.
- `contactChannelSchema`, `contactStatusSchema` — primitive schemas reused across composites.
- `parseOrThrow(schema, input, label)` — typed wrapper that throws `ContactsError(INVALID_INPUT)` on failure, preserving the underlying `ZodError` via `cause`.
## Usage: Basic
```ts
import {
createContactInputSchema,
parseOrThrow,
} from "@suaveplan/contacts";
const input = parseOrThrow(
createContactInputSchema,
{
tenantId: "acme",
displayName: "Alice",
channels: [
{ type: "email", value: "alice@x.io", primary: true, verified: false },
],
},
"MyService.createContact",
);
// `input` is typed as the schema's inferred type.
```
## Usage: Advanced
```ts
import {
audienceResolveOptionsSchema,
CONTACTS_CODES,
ContactsError,
parseOrThrow,
} from "@suaveplan/contacts";
function resolveSafely(raw: unknown) {
try {
const opts = parseOrThrow(
audienceResolveOptionsSchema,
raw,
"resolveSafely",
);
return resolver.resolve(opts);
} catch (err) {
if (
err instanceof ContactsError &&
err.code === CONTACTS_CODES.INVALID_INPUT
) {
logger.warn({ paths: err.message, cause: err.cause }, "bad audience options");
return null;
}
throw err;
}
}
```
## API Reference
### Schema constants
Each `*Schema` is a `z.ZodType<T>` whose inferred shape matches the corresponding public TypeScript interface. Schemas use `z.record(z.string(), z.unknown())` for open metadata bags, `z.array(...)` for collections, and `z.enum(...)` for closed unions like `ContactStatus`.
### `parseOrThrow<T>(schema, input, label): T`
- `schema: z.ZodType<T>` — the schema to apply.
- `input: unknown` — caller-provided data.
- `label: string` — appears in the error message for traceability (e.g. the calling method name).
- Returns the parsed value typed as `T`.
- Throws `ContactsError(INVALID_INPUT)` whose `cause` is the underlying `ZodError`. The message format is `"{label} input failed validation: {field}: {issue}; …"`.
## Implementation Notes
All managers funnel through `parseOrThrow` before performing any side effect, which means the order of effects is deterministic: validation always runs first. Test suites depend on this property to assert "invalid input does not persist anything."
The schemas use `z.string().min(1)` to reject empty strings explicitly because Zod treats `""` as a valid string by default. This matches what consumers reasonably expect ("displayName is required" should reject empty too).
Field-mapping shapes (`importOptionsSchema.fieldMapping`) use `z.record(z.string(), z.string().optional())` to permit caller-defined keys without locking the surface. The TypeScript interface for `FieldMapping` exposes the named slots as an open index signature so the schema and the type stay in sync without duplicating every column.

View File

@@ -0,0 +1,93 @@
# contact-store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `ContactStore` storage interface that `ContactManager` and `AudienceResolver` consume, plus the `InMemoryContactStore` reference implementation used for testing, and any single-process deployment that doesn't need a database. The in-memory variant is fully deterministic when `clock` and `idGenerator` are injected from `@suaveplan/types`, which makes the package safe to use under `FakeClock`-driven test harnesses and replay-style integration tests.
## Features
- `ContactStore` interface — eight methods: `create`, `getById`, `update`, `delete`, `list`, `findByChannelValue`, `findByIds`, `countByTenant`.
- `InMemoryContactStore` — concrete implementation with tenant-scoped reads, channel-search, tag / status / search / channel-type filters, offset+limit pagination, default limit 50.
- Injectable `Clock` for deterministic `createdAt` / `updatedAt`.
- Injectable `IdGenerator` for deterministic ids; default counter `contact_1, contact_2, …`.
- `ContactsError(CONTACT_NOT_FOUND)` on update of a missing id; never throws bare `Error`.
## Usage: Basic
```ts
import {
ContactManager,
InMemoryContactStore,
} from "@suaveplan/contacts";
const store = new InMemoryContactStore();
const mgr = new ContactManager({ store });
const alice = await mgr.create({
tenantId: "acme",
displayName: "Alice",
channels: [
{ type: "email", value: "alice@x.io", primary: true, verified: false },
],
});
```
## Usage: Advanced
```ts
import {
InMemoryContactStore,
} from "@suaveplan/contacts";
// Test setup — deterministic Clock + IdGenerator
let pinned = 1_700_000_000_000;
const store = new InMemoryContactStore({
clock: () => pinned,
idGenerator: (() => {
let i = 0;
return () => `c_${++i}`;
})(),
});
const a = await store.create({
tenantId: "t1",
displayName: "A",
channels: [],
});
console.log(a.id); // "c_1"
console.log(a.createdAt.getTime()); // 1700000000000
pinned += 10_000;
const u = await store.update(a.id, { status: "archived" });
console.log(u.updatedAt.getTime()); // 1700000010000
```
## API Reference
### `interface ContactStore`
Every method returns a `Promise` so the same interface works for in-memory, SurrealDB, Postgres, or any other future backend.
- `create(input)` — persist a new contact and return it with id + timestamps.
- `getById(id)` — return the contact or `null`.
- `update(id, input)` — patch fields; raises `CONTACT_NOT_FOUND` when id is missing.
- `delete(id)` — idempotent; no error when id is missing.
- `list(query)` — paginated read; returns `{ contacts, total }`.
- `findByChannelValue(tenantId, type, value)` — first contact whose channel matches in that tenant; `null` if none.
- `findByIds(ids)` — preserves input order, drops missing ids.
- `countByTenant(tenantId)` — number of contacts in the tenant.
### `class InMemoryContactStore implements ContactStore`
Constructor takes `{ clock?, idGenerator? }`. Defaults: `clock = () => Date.now()`, `idGenerator` is a per-instance monotonic counter formatted `contact_<n>`. The instance owns a private `Map<id, Contact>`.
## Implementation Notes
Search filter is case-insensitive substring match across `displayName`, `firstName`, `lastName`, and every channel value. Tag filter is "any-of" (contact has at least one of the requested tags). Empty `tags: []` is treated as "no filter" so callers can pass canonical request shapes without special-casing.
Pagination uses array-slice on the filtered set, which is fine for the in-memory tier but means database adapters MUST implement equivalent semantics (LIMIT / OFFSET with the same filter order) to keep `ContactStore` substitutable.
The default `IdGenerator` is intentionally per-instance, not per-class, so two stores in the same process don't collide. For production deployments where deterministic ids matter across restarts, inject your own (e.g. ULID or KSUID generator).

View File

@@ -0,0 +1,88 @@
# group-store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `GroupStore` storage interface that `GroupManager` and `AudienceResolver` consume, plus the `InMemoryGroupStore` reference implementation. `GroupStore` exposes `generateId()` on the interface (not the concrete class) so `GroupManager.create` can call it directly without a type-launder cast — fixing the upstream `(store as InMemoryGroupStore).generateId` pattern. The in-memory variant accepts injectable `Clock` and `IdGenerator` for deterministic test runs.
## Features
- `GroupStore` interface — nine methods including `generateId`, `create`, `getById`, `update`, `delete`, `list`, `getChildren`, `getDescendants`, `getRoots`, `findByIds`.
- `getDescendants(path)` uses `path.startsWith(parent.path + '/')` so it excludes the parent itself; matches what consumers expect.
- `InMemoryGroupStore` — concrete implementation with tenant-scoped reads, parent + search filters, offset+limit pagination.
- Injectable `Clock` for deterministic `updatedAt` on every update.
- Injectable `IdGenerator` for deterministic group ids; default counter `group_1, group_2, …`.
- `ContactsError(GROUP_NOT_FOUND)` on update of a missing id; never throws bare `Error`.
## Usage: Basic
```ts
import {
GroupManager,
InMemoryGroupStore,
} from "@suaveplan/contacts";
const store = new InMemoryGroupStore();
const mgr = new GroupManager({ store });
const root = await mgr.create({ tenantId: "acme", name: "All Customers" });
const vip = await mgr.create({
tenantId: "acme",
name: "VIP",
parentId: root.id,
});
console.log(vip.path); // "/all-customers/vip"
```
## Usage: Advanced
```ts
import { InMemoryGroupStore } from "@suaveplan/contacts";
import type { GroupStore } from "@suaveplan/contacts";
// Custom GroupStore for an alternate backend
class MyGroupStore implements GroupStore {
public generateId(): string {
return crypto.randomUUID();
}
// … implement create / getById / update / etc.
public async create(g: Parameters<GroupStore["create"]>[0]) { return g; }
public async getById() { return null; }
public async update(_id: string) { throw new Error("not implemented"); }
public async delete() {}
public async list() { return { groups: [], total: 0 }; }
public async getChildren() { return []; }
public async getDescendants() { return []; }
public async getRoots() { return []; }
public async findByIds() { return []; }
}
```
## API Reference
### `interface GroupStore`
- `generateId()` — sync; returns a new id. Called by `GroupManager.create`.
- `create(group)` — persist a fully-formed `Group` record.
- `getById(id)` — return the group or `null`.
- `update(id, patch)` — partial update; raises `GROUP_NOT_FOUND` when missing.
- `delete(id)` — idempotent.
- `list(query)` — paginated read.
- `getChildren(parentId)` — direct children only.
- `getDescendants(path)` — strict descendants, excluding the path itself.
- `getRoots(tenantId)` — top-level groups (`parentId === null`) for the tenant.
- `findByIds(ids)` — preserves input order, drops missing ids.
### `class InMemoryGroupStore implements GroupStore`
Constructor takes `{ clock?, idGenerator? }` with the same defaults as `InMemoryContactStore`. The instance owns a private `Map<id, Group>`.
## Implementation Notes
`getDescendants` uses the `path` field rather than recursively walking `parentId` chains. This is O(N) over the whole group set but constant-time per match, which is dramatically faster than the recursive walk for any non-trivial hierarchy. A SQL implementation would index `path` and use `LIKE 'parent_path/%'`.
Promoting `generateId()` to the interface (originally a concrete-class-only method on `InMemoryGroupStore`) fixes the type-launder hole in upstream — `GroupManager` no longer needs `(store as InMemoryGroupStore).generateId`, and custom `GroupStore` implementations are obligated to provide an id generator that matches their backend's id space (UUID, ULID, SurrealId, etc.).
Filtering by `parentId` distinguishes three cases: `undefined` means "any parent" (no filter), `null` means "root groups only", a string means "direct children of that id". Callers in the test suite exercise all three to guard against accidental conflation.

View File

@@ -0,0 +1,82 @@
# membership-store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `MembershipStore` storage interface that `MembershipManager`, `ContactMerger`, and `AudienceResolver` consume, plus the `InMemoryMembershipStore` reference implementation. The store is the canonical home for many-to-many contact-to-group relations, with a composite-key index on `(groupId, contactId)` so add/exists/remove are constant-time. Like the sibling stores, the in-memory variant accepts an injectable `IdGenerator` so test runs and replay-style integration tests stay deterministic.
## Features
- `MembershipStore` interface — `generateId`, `add`, `remove`, `getByGroup`, `getByContact`, `exists`, `removeAllForContact`, `removeAllForGroup`, `getContactIdsByGroups`, `countByGroup`.
- `add` raises `ContactsError(MEMBERSHIP_EXISTS)` on duplicate `(groupId, contactId)` — `MembershipManager` translates this into its own duplicate-membership handling.
- Injectable `IdGenerator` for deterministic membership ids; default `membership_1, membership_2, …`.
- `getContactIdsByGroups(groupIds)` returns a flat array of contact ids across the requested groups — the canonical bulk fetch used by `AudienceResolver`.
- `removeAllForContact` / `removeAllForGroup` are the bulk-deletion paths used by merge and group-delete cascades.
## Usage: Basic
```ts
import {
InMemoryMembershipStore,
MembershipManager,
InMemoryContactStore,
InMemoryGroupStore,
} from "@suaveplan/contacts";
const ms = new InMemoryMembershipStore();
const mgr = new MembershipManager({
contactStore: new InMemoryContactStore(),
groupStore: new InMemoryGroupStore(),
membershipStore: ms,
});
```
## Usage: Advanced
```ts
import { InMemoryMembershipStore } from "@suaveplan/contacts";
// Deterministic id generator for test snapshots
let n = 0;
const store = new InMemoryMembershipStore({
idGenerator: () => `m_${++n}`,
});
await store.add({
id: store.generateId(),
groupId: "g_1",
contactId: "c_1",
addedAt: new Date(0),
});
const contactIds = await store.getContactIdsByGroups(["g_1", "g_2"]);
```
## API Reference
### `interface MembershipStore`
- `generateId()` — sync; produces a new membership id.
- `add(membership)` — raises `MEMBERSHIP_EXISTS` on duplicate `(groupId, contactId)`.
- `remove(groupId, contactId)` — idempotent.
- `getByGroup(groupId)` / `getByContact(contactId)` — full membership records.
- `exists(groupId, contactId)` — boolean lookup, used by `MembershipManager` and `ContactMerger`.
- `removeAllForContact(contactId)` — bulk delete; called by `ContactMerger` after transfer.
- `removeAllForGroup(groupId)` — bulk delete; called by `GroupManager.delete` cascade in larger deployments.
- `getContactIdsByGroups(groupIds)` — flat array, may contain duplicates if a contact is in multiple groups (resolver dedups).
- `countByGroup(groupId)` — direct count, distinct from `Group.contactCount` which the manager maintains.
### `class InMemoryMembershipStore implements MembershipStore`
Constructor takes `{ idGenerator? }`. The instance owns a private `Map<"groupId:contactId", GroupMembership>` for constant-time composite-key access.
## Implementation Notes
The composite key (`groupId:contactId` joined with a `:`) is a private encoding detail and never leaks through the API. It avoids a nested `Map<groupId, Map<contactId, GroupMembership>>` while keeping every operation O(1).
`getContactIdsByGroups` returns duplicates when a contact is a member of more than one of the requested groups. This is intentional — `AudienceResolver` uses the duplicate count to populate `AudienceResult.duplicatesRemoved`. Database adapters should preserve this contract or compute the equivalent count separately.
Membership ids are produced by `generateId()` rather than computed from `(groupId, contactId)` because some consumers (audit logs, sync engines) want a stable, opaque membership identity that survives a remove+re-add. The composite key is internal; the public identity is `id`.

View File

@@ -0,0 +1,94 @@
# tree-utils
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pure tree utilities for building, flattening, traversing, and inspecting `GroupTreeNode<TMeta>` hierarchies. None of these functions perform IO, call `Date.now()`, or pull randomness — they're deterministic transformations over arrays. Consumers build a tree from a flat group list with `buildGroupTree`, render it with `flattenGroupTree` (which annotates every row with `level`, `hasChildren`, `isLastChild` for tree-prefix rendering), and inspect it with the named helpers.
## Features
- `buildGroupTree(groups)` — assembles a `GroupTreeNode[]` from a flat list, promoting orphans to roots when their parent is absent.
- `flattenGroupTree(roots)` — depth-first flatten with rendering metadata.
- `getAncestorPaths(path)` — returns every proper ancestor of a slash-separated path.
- `validateNoCircularRef(groupId, targetParentId, groups)` — returns `true` when the move is safe, `false` when it would close a cycle.
- `countTreeContacts(node)` — recursive sum of `contactCount` across the subtree.
- `findInTree(roots, groupId)` — DFS lookup returning the node or `null`.
- `collectAllGroupIds(roots)` — DFS pre-order enumeration of every group id.
## Usage: Basic
```ts
import {
buildGroupTree,
flattenGroupTree,
} from "@suaveplan/contacts";
const flat = (await groups.list({ tenantId: "acme" })).groups;
const tree = buildGroupTree(flat);
const rows = flattenGroupTree(tree);
for (const row of rows) {
console.log(`${" ".repeat(row.level * 2)}${row.group.name}`);
}
```
## Usage: Advanced
```ts
import {
collectAllGroupIds,
countTreeContacts,
findInTree,
getAncestorPaths,
validateNoCircularRef,
} from "@suaveplan/contacts";
// Render a breadcrumb from a group's path
const crumbs = getAncestorPaths("/all-customers/vip/asia");
// → ["/all-customers", "/all-customers/vip"]
// Guard against creating a cycle before issuing a move
if (!validateNoCircularRef(groupId, targetParentId, allGroups)) {
throw new Error("would create a cycle");
}
// Aggregate counts across a subtree
const tree = buildGroupTree(allGroups);
const node = findInTree(tree, "g_vip");
if (node) console.log(`VIP subtree: ${countTreeContacts(node)} contacts`);
// Bulk-export every group id reachable from a subtree
const ids = collectAllGroupIds(tree);
```
## API Reference
### `buildGroupTree<TMeta>(groups): GroupTreeNode<TMeta>[]`
Builds an in-memory map by id, then iterates a second time to wire children to parents. Groups whose `parentId` is missing from the input are promoted to roots — this matches the typical "I've fetched a subtree by tenant" use case.
### `flattenGroupTree<TMeta>(roots, level = 0): FlattenedGroupNode<TMeta>[]`
DFS flatten. The root array's index determines `isLastChild` for top-level entries; recursion uses the child array's index for nested entries.
### `getAncestorPaths(path): string[]`
Splits on `/`, drops empties, and returns every proper prefix as a `/`-prefixed string. Returns `[]` for `/`, `/a`, or any single-segment path.
### `validateNoCircularRef(groupId, targetParentId, groups): boolean`
Returns `true` when moving `groupId` under `targetParentId` would NOT create a cycle. Returns `false` when source and target are the same, when the source is missing from the input set, or when traversing target's ancestor chain reaches the source. Note the inverted semantics: `true` means "safe", `false` means "blocked".
### `countTreeContacts(node)` / `findInTree(roots, id)` / `collectAllGroupIds(roots)`
Pure recursive helpers. `countTreeContacts` sums `node.group.contactCount` plus every descendant's contribution. `findInTree` returns the first matching node or `null`. `collectAllGroupIds` returns a DFS pre-order id list (root then children left-to-right).
## Implementation Notes
`buildGroupTree` uses a `MutableNode<TMeta>` interface internally so children can be appended without violating the publicly-exposed `readonly` arrays on `GroupTreeNode`. The function returns the structurally-compatible `GroupTreeNode[]` shape and consumers see the immutable surface.
`validateNoCircularRef` uses ancestor walking (via `parentId` chain) for the target side because callers typically have the full group set in memory by this point — the alternative (descendant scanning via `path`) requires the source group's path, which means an extra lookup that the chain-walk avoids.
All functions are exported as named functions (not class methods) so consumers can tree-shake unused utilities away. Importing only `getAncestorPaths` from `@suaveplan/contacts/tree/tree-utils` pulls only that function's code into the bundle.

View File

@@ -0,0 +1,85 @@
# contact.types
## Purpose
Defines the runtime-free TypeScript shapes for contacts in `@suaveplan/contacts`: the `Contact<TMeta>` entity, its `ContactChannel` and `ContactStatus` discriminators, and the `CreateContactInput` / `UpdateContactInput` / `ContactQuery` / `ContactListResult` / `MergeResult` companions consumed by every manager and store in the package. Every field is `readonly` so accidental in-place mutation is caught at compile time. The optional `TMeta` generic lets consumers pin their domain's metadata shape without losing inference on the entity.
## Features
- `Contact<TMeta>` — immutable entity with id, tenant, channels, tags, status, metadata, and ISO timestamps.
- `ContactChannel` — `{ type, value, primary, verified, metadata? }` discriminator for email, sms, phone, slack, etc.
- `ContactStatus` — closed union `"active" | "unsubscribed" | "bounced" | "complained" | "archived"`.
- `CreateContactInput<TMeta>` — input shape for `ContactManager.create` (optional `tags`, `status`, `metadata`).
- `UpdateContactInput<TMeta>` — all-fields-optional patch shape; metadata merges shallowly.
- `ContactQuery` — pagination + filter shape (`search`, `tags`, `status`, `channelType`, `limit`, `offset`).
- `ContactListResult<TMeta>` — `{ contacts, total }` envelope returned by list operations.
- `MergeResult<TMeta>` — `{ mergedContact, removedContactIds }` envelope returned by `ContactMerger.merge`.
## Usage: Basic
```ts
import type { Contact, CreateContactInput } from "@suaveplan/contacts";
const input: CreateContactInput = {
tenantId: "acme",
displayName: "Alice",
channels: [
{ type: "email", value: "alice@x.io", primary: true, verified: false },
],
};
const persisted: Contact = await contacts.create(input);
```
## Usage: Advanced
```ts
import type { Contact, ContactQuery } from "@suaveplan/contacts";
interface MarketingMeta {
readonly source: string;
readonly utm: Record<string, string>;
}
const query: ContactQuery = {
tenantId: "acme",
tags: ["vip"],
channelType: "email",
limit: 100,
offset: 0,
};
const page = await contacts.list(query);
const typed: readonly Contact<MarketingMeta>[] = page.contacts as readonly Contact<
MarketingMeta
>[];
const sources = typed.map((c) => c.metadata.source);
```
## API Reference
### `Contact<TMeta = Record<string, unknown>>`
Immutable record returned by every read path. All fields are `readonly`. `createdAt` and `updatedAt` are JavaScript `Date` objects derived from the injected `Clock`.
### `ContactChannel`
A communication endpoint. `primary` is callers' choice; the package promotes the first channel to primary if none are flagged. `metadata` is an open bag for caller-specific provenance (verification timestamps, source webhook id, etc.).
### `ContactStatus`
Closed union. Adding a status is a breaking change; consumers exhaustively switch on it.
### `CreateContactInput<TMeta>` / `UpdateContactInput<TMeta>` / `ContactQuery`
Input shapes; each has a matching Zod schema in `src/schemas/schemas.ts` and is validated at the manager boundary before any side effect.
### `ContactListResult<TMeta>` / `MergeResult<TMeta>`
Result envelopes. `ContactListResult.total` is the unpaginated count; `contacts` is the paginated slice. `MergeResult.removedContactIds` is the list of secondaries that were merged into the primary and deleted from the store.
## Implementation Notes
The `TMeta` generic flows through `CreateContactInput`, `UpdateContactInput`, `ContactListResult`, `MergeResult`, and `Contact` so a single `Contact<MyMeta>` declaration narrows every read. Defaults to `Record<string, unknown>` to keep the surface convenient when consumers don't care.
Timestamps are wall-clock-equivalent (`new Date(clock())`) — the injected `Clock` returns milliseconds. Tests use a `FakeClock` from `@suaveplan/testing` to pin reproducible timestamps.
`readonly` is enforced through every array and field. Callers wanting to mutate locally should spread (`[...contact.channels, newChannel]`) and then route through `ContactManager.update`.

View File

@@ -0,0 +1,74 @@
# group.types
## Purpose
Defines the runtime-free TypeScript shapes for groups in `@suaveplan/contacts`: the `Group<TMeta>` entity (with its hybrid adjacency-list + materialized-path fields), the `CreateGroupInput` / `UpdateGroupInput` / `GroupQuery` companions, the `GroupListResult` envelope returned by list operations, and the `GroupTreeNode<TMeta>` shape consumed by the tree utilities. Every field is `readonly` so consumers see compile-time errors on accidental in-place mutation.
## Features
- `Group<TMeta>` — immutable entity with id, tenant, name, optional description, `parentId` (adjacency), `path` (materialized), `depth`, metadata, direct + recursive contact counts, and timestamps.
- `CreateGroupInput<TMeta>` — input shape for `GroupManager.create` (`parentId` optional / nullable for roots).
- `UpdateGroupInput<TMeta>` — all-fields-optional patch shape; `name` change triggers slug + path propagation.
- `GroupQuery` — pagination + filter shape (`parentId`, `search`, `limit`, `offset`).
- `GroupListResult<TMeta>` — `{ groups, total }` envelope returned by list operations.
- `GroupTreeNode<TMeta>` — `{ group, children }` recursive shape consumed by the tree utilities (`buildGroupTree`, `flattenGroupTree`, …).
## Usage: Basic
```ts
import type { CreateGroupInput, Group } from "@suaveplan/contacts";
const input: CreateGroupInput = {
tenantId: "acme",
name: "VIP Customers",
};
const persisted: Group = await groups.create(input);
console.log(persisted.path); // "/vip-customers"
```
## Usage: Advanced
```ts
import { buildGroupTree } from "@suaveplan/contacts";
import type { Group, GroupTreeNode } from "@suaveplan/contacts";
interface OrgMeta {
readonly costCenter: string;
}
const flat: readonly Group<OrgMeta>[] = (await groups.list({ tenantId: "acme" })).groups as readonly Group<OrgMeta>[];
const tree: GroupTreeNode<OrgMeta>[] = buildGroupTree(flat);
function totals(node: GroupTreeNode<OrgMeta>): number {
return node.group.totalContactCount;
}
```
## API Reference
### `Group<TMeta = Record<string, unknown>>`
Immutable record. `path` is a slash-prefixed slug chain (e.g. `/all-customers/vip`) maintained by `GroupManager`; consumers MUST NOT construct or rewrite it directly. `depth` is the number of `/`-separated segments minus one (root = 0). `contactCount` is the direct count; `totalContactCount` is the recursive count maintained by `MembershipManager` via ancestor walks.
### `CreateGroupInput<TMeta>`
`parentId` may be omitted, `null`, or an existing group id. Validation lives in `createGroupInputSchema`.
### `UpdateGroupInput<TMeta>`
Patch shape. `metadata` is shallow-merged with the existing record; supply explicit `null` semantics in your meta type if you need three-state fields.
### `GroupQuery`
`parentId === undefined` means "any parent"; `parentId === null` filters to root groups; a string id filters to that parent's direct children.
### `GroupListResult<TMeta>` / `GroupTreeNode<TMeta>`
`GroupListResult.total` is the unpaginated count; `groups` is the paginated slice. `GroupTreeNode` is recursive and `readonly`; mutate only through the manager.
## Implementation Notes
The hybrid adjacency-list + materialized-path strategy was chosen because it lets the store answer "all descendants of group X" with a single `path.startsWith(parent.path + '/')` scan rather than a recursive query — important for the in-memory tier and pleasant for SQL stores too.
`path` is regenerated on every name change; `GroupManager.update` cascades the rewrite through every descendant via `store.getDescendants`. Tests pin the propagation behaviour to guard against regression.
`contactCount` and `totalContactCount` are stored on the entity (not computed on read) so list views can render hierarchical totals without an extra round-trip per group. They're maintained by `MembershipManager.addContactToGroup` / `removeContactFromGroup` walking the ancestor chain.

View File

@@ -0,0 +1,80 @@
# membership.types
## Purpose
Defines the runtime-free TypeScript shapes for many-to-many contact-group memberships and the audience-resolution surface in `@suaveplan/contacts`: the `GroupMembership` join record, the `AddMemberInput` shape consumed by managers, the `AudienceResolveOptions` bag passed to `AudienceResolver.resolve`, and the `AudienceResult<TMeta>` envelope returned from resolution. Every field is `readonly` so consumers can't accidentally mutate a returned record in place.
## Features
- `GroupMembership` — `{ id, groupId, contactId, addedAt, metadata? }` join record returned by every membership-store read.
- `AddMemberInput` — minimal `{ groupId, contactId, metadata? }` input bag accepted by managers.
- `AudienceResolveOptions` — group ids to include, optional exclude lists (groups + contacts), optional channel and status filters, dedup flag.
- `AudienceResult<TMeta>` — `{ contacts, totalCount, groupsResolved, duplicatesRemoved }` envelope reporting both the materialized contact slice and the bookkeeping numbers a UI typically wants.
- All shapes flow through the corresponding Zod schemas in `src/schemas/schemas.ts` before being passed to any side effect.
## Usage: Basic
```ts
import type { AddMemberInput, GroupMembership } from "@suaveplan/contacts";
const input: AddMemberInput = {
groupId: "g_1",
contactId: "c_1",
};
const membership: GroupMembership = await memberships.addContactToGroup(
input.groupId,
input.contactId,
input.metadata,
);
```
## Usage: Advanced
```ts
import type { AudienceResolveOptions, AudienceResult } from "@suaveplan/contacts";
interface CampaignMeta {
readonly campaignId: string;
}
const opts: AudienceResolveOptions = {
groupIds: ["g_vip", "g_recent"],
excludeGroupIds: ["g_unsubscribed"],
channelType: "email",
statusFilter: ["active"],
deduplicate: true,
};
const result: AudienceResult<CampaignMeta> = (await resolver.resolve(
opts,
)) as AudienceResult<CampaignMeta>;
console.log(
`${result.totalCount} contacts across ${result.groupsResolved} groups ` +
`(${result.duplicatesRemoved} duplicates removed)`,
);
```
## API Reference
### `GroupMembership`
Returned by every membership read (`getByGroup`, `getByContact`, `exists` indirectly, `add`). `id` is supplied by the store's `generateId()` so consumers can join across additional caller-defined tables.
### `AddMemberInput`
Convenience shape — managers also accept positional `(groupId, contactId, metadata?)` arguments. Use the input when you have caller-side validation already in a record form.
### `AudienceResolveOptions`
`groupIds` is the include set; every descendant of each entry is added via `GroupStore.getDescendants`. `excludeGroupIds` is symmetric — each entry's descendants are removed. `excludeContactIds` filters the final contact set. `channelType` keeps only contacts that own a channel of that type. `statusFilter` keeps only contacts whose status is in the list. `deduplicate` defaults to true conceptually — the resolver always dedups by contact id today; the flag is reserved for forward-compatible behaviour.
### `AudienceResult<TMeta>`
`contacts` is the post-filter contact slice (typed as `readonly Contact<TMeta>[]`). `totalCount` matches `contacts.length`. `groupsResolved` is the cardinality of the expanded-and-excluded group set. `duplicatesRemoved` is the count of contact-id encounters skipped because the id was already in the include set.
## Implementation Notes
`metadata` on `GroupMembership` is an open `Record<string, unknown>` rather than generic — membership metadata is intentionally schemaless because it's typically caller-supplied join-time data (e.g. `{ source: "import", batch: 42 }`).
The `AudienceResolveOptions` shape mirrors what an email-campaign UI typically composes: include groups, exclude groups, exclude individuals, optional channel filter for "email-only" sends, optional status filter to skip unsubscribed. Adding new fields is additive and non-breaking as long as defaults are sensible.
The result envelope reports `groupsResolved` and `duplicatesRemoved` so a UI can show "we found 1,234 people across 7 groups, 12 of whom were in more than one group." That kind of feedback prevents users second-guessing whether the audience selection is correct.

View File

@@ -0,0 +1,61 @@
# @suaveplan/macos-contacts - Universal macOS Contacts.app bridge via AppleScript: injectable AppleScriptRunner seam (run tests on any platform without macOS), free-function ContactParser surface, injectable Clock for sync timestamps, six OTel counters + two histograms with outcome-partitioned attributes, typed MacOSContactsError hierarchy with stable MACOS_CONTACTS.* code registry, and Zod input validation at every boundary.
Universal bridge to macOS Contacts.app via AppleScript. Reads contacts and groups through the platform `osascript` binary, parses the output into typed records, and exposes the result behind an injectable runner seam so tests run on any platform without macOS.
<!-- begin:auto-docs -->
## Documentation tree
- [applescript](./src/applescript/applescript.md) — AppleScript template builders for querying macOS Contacts.app. Each function returns a complete AppleScript string suit…
- [applescript](./src/applescript/applescript.md) — AppleScript template builders for querying macOS Contacts.app. Each function returns a complete AppleScript string suit…
- [bridge](./src/bridge/bridge.md) — `MacOSContactsBridge` is the high-level entry point for `@suaveplan/macos-contacts`. It composes the four building bloc…
- [bridge](./src/bridge/bridge.md) — `MacOSContactsBridge` is the high-level entry point for `@suaveplan/macos-contacts`. It composes the four building bloc…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed `MacOSContactsError` class for `@suaveplan/macos-contacts`. Every fail…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed `MacOSContactsError` class for `@suaveplan/macos-contacts`. Every fail…
- [executor](./src/executor/executor.md) — Defines the `AppleScriptRunner` injectable seam that `MacOSContactsBridge` depends on, plus a default implementation ba…
- [executor](./src/executor/executor.md) — Defines the `AppleScriptRunner` injectable seam that `MacOSContactsBridge` depends on, plus a default implementation ba…
- [otel](./src/otel/instruments.md) — Bundle of OpenTelemetry instruments emitted by `@suaveplan/macos-contacts`. The accessor `getMacOSContactsInstruments()…
- [instruments](./src/otel/instruments.md) — Bundle of OpenTelemetry instruments emitted by `@suaveplan/macos-contacts`. The accessor `getMacOSContactsInstruments()…
- [parser](./src/parser/parser.md) — Pure free-function parser for the raw text stream emitted by the AppleScript templates that drive `MacOSContactsBridge`…
- [parser](./src/parser/parser.md) — Pure free-function parser for the raw text stream emitted by the AppleScript templates that drive `MacOSContactsBridge`…
- [schemas](./src/schemas/schemas.md) — Zod schemas guarding every public input boundary of `@suaveplan/macos-contacts`. Validation runs before any side effect…
- [schemas](./src/schemas/schemas.md) — Zod schemas guarding every public input boundary of `@suaveplan/macos-contacts`. Validation runs before any side effect…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/macos-contacts
```
## Quick start
```ts
import { MacOSContactsBridge } from "@suaveplan/macos-contacts";
const bridge = new MacOSContactsBridge({ timeoutMs: 30_000 });
if (await bridge.isAvailable()) {
const contacts = await bridge.getAllContacts();
console.log(`${contacts.length} contacts`);
const family = await bridge.getGroupMembers("Family");
}
```
## Features
- Injectable `AppleScriptRunner` seam — tests run on any platform without macOS by passing a fake runner that returns fixture strings.
- Free-function parser surface (`parseContacts`, `parseGroups`, `normalizePhone`) — no static-only class, tree-shakeable at function granularity.
- Typed `MacOSContactsError` hierarchy with the `MACOS_CONTACTS.*` code registry (`INVALID_INPUT`, `CONTACTS_NOT_FOUND`, `CONTACTS_PERMISSION_DENIED`, `CONTACTS_ACCESS_FAILED`, `CONTACTS_PARSE_FAILED`, `CONTACTS_UNAVAILABLE`).
- Six OTel counters (partitioned by `outcome`) and two histograms wired through `@suaveplan/opentelemetry`.
- Zod input validation at every public boundary; injectable `Clock` for sync timestamps; no `Date.now()` / `new Date()` in the production hot path.
- `sync()` and `isAvailable()` never throw — failures surface through structured result fields.
## Modules
- [`error-codes`](./src/error-codes/error-codes.md) — stable code registry + typed `MacOSContactsError`.
- [`schemas`](./src/schemas/schemas.md) — Zod schemas + `parseOrThrow` boundary helper.
- [`otel/instruments`](./src/otel/instruments.md) — OpenTelemetry counter + histogram bundle.
- [`parser`](./src/parser/parser.md) — free-function parser for raw AppleScript output.
- [`applescript`](./src/applescript/applescript.md) — AppleScript template builders.
- [`executor`](./src/executor/executor.md) — `AppleScriptRunner` interface + default platform implementation.
- [`bridge`](./src/bridge/bridge.md) — `MacOSContactsBridge` orchestrator.

View File

@@ -0,0 +1,74 @@
# applescript
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
AppleScript template builders for querying macOS Contacts.app. Each function returns a complete AppleScript string suitable for handing to the platform `osascript` binary via the `AppleScriptRunner` seam. Templates are pure — no side effects, no IO — so consumers can preview or modify the script before invoking it (e.g. for logging, custom escaping, or wrapping in additional `tell` blocks).
## Features
- `getAllContactsScript()` — every person, full detail (id, names, organisation, birthday, notes, image flag, phones, emails, addresses).
- `searchContactsScript(query)` — every person whose name contains the supplied query, full detail. Escapes embedded `"` characters before splicing the query into the script.
- `getGroupsScript()` — every group with member counts.
- `getGroupMembersScript(groupName)` — every person in a named group, full detail. Escapes embedded `"` characters before splicing the name into the script.
## Usage: Basic
```ts
import { getAllContactsScript } from "@suaveplan/macos-contacts";
const script = getAllContactsScript();
// Use with any AppleScriptRunner, including the bundled default:
// const runner = createDefaultAppleScriptRunner({ timeoutMs: 30_000 });
// const raw = await runner.run(script);
```
## Usage: Advanced
```ts
import {
createDefaultAppleScriptRunner,
parseContacts,
searchContactsScript,
} from "@suaveplan/macos-contacts";
async function findByName(query: string) {
const runner = createDefaultAppleScriptRunner({ timeoutMs: 10_000 });
const script = searchContactsScript(query);
const raw = await runner.run(script);
return parseContacts(raw);
}
// Embedded quotes are escaped automatically:
const safe = searchContactsScript('John "Jack" Doe');
// safe contains name contains "John \"Jack\" Doe" in valid AppleScript form.
```
## API Reference
### `getAllContactsScript(): string`
Returns a complete AppleScript that emits one `---CONTACT---`-delimited block per person. Output format matches the contract parsed by `parseContacts`.
### `searchContactsScript(query: string): string`
Same output format as `getAllContactsScript`, restricted to people whose `name contains` the supplied query. The query is splice-escaped so embedded `"` characters do not break the script.
### `getGroupsScript(): string`
Returns a complete AppleScript that emits one line per group in `id|||name|||memberCount` form, suitable for `parseGroups`.
### `getGroupMembersScript(groupName: string): string`
Returns a complete AppleScript that resolves `first group whose name is "<escaped groupName>"` and emits the contact loop over its members. Output format matches `parseContacts`.
## Implementation Notes
Templates are built from shared sub-strings (`CONTACT_PROPERTIES`, `CONTACT_DETAILS`, `contactLoop`) so the four public builders stay short and reviewable. The output schema is deliberately string-stable: any change to the field order or delimiters in this module must be matched by a change in `parser.ts`, and both must be covered by the bridge's end-to-end fixture tests.
Quote-escaping is the bare minimum needed to keep the splice safe — replace `"` with `\"`. AppleScript does not interpret backslashes inside string literals the way C does, but `osascript -e` accepts the doubled form, and macOS' AppleScript editor treats it identically. If a future code path needs richer escaping (e.g. for embedded `\\` or `\n`), extend the helpers here rather than open-coding in the bridge.
The bridge depends on these builders directly; consumers that want the raw scripts (for logging, dry-run preview, or custom transports) can import them from the package root or the `./applescript/applescript` subpath.

View File

@@ -0,0 +1,115 @@
# bridge
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MacOSContactsBridge` is the high-level entry point for `@suaveplan/macos-contacts`. It composes the four building blocks — `AppleScriptRunner` (IO), the AppleScript template builders (script generation), the parser free functions (output → typed records), and the typed error registry — into a single object with `getAllContacts` / `searchContacts` / `getContactById` / `getGroups` / `getGroupMembers` / `sync` / `isAvailable`. Every public method validates its input via Zod, wraps the runner with typed `MacOSContactsError` mapping, and emits the corresponding OTel counter (with `outcome` attribute) and the shared `applescript_duration_ms` histogram.
## Features
- Injectable `AppleScriptRunner` — tests run on any platform without macOS; production wires `createDefaultAppleScriptRunner`.
- Injectable `Clock` (from `@suaveplan/types`) — `SyncResult.syncedAt` and the duration histograms use the injected clock; no `Date.now()` or `new Date()` in the hot path.
- Zod-validated config — `timeoutMs` must be a positive integer; invalid config raises `MacOSContactsError(INVALID_INPUT)` at construction time.
- Typed error mapping — runner rejections matching `/not authorized|permission denied|access denied|user denied/i` map to `CONTACTS_PERMISSION_DENIED`; everything else maps to `CONTACTS_ACCESS_FAILED`. Discriminants (`query`, `groupName`, `contactId`) flow onto the raised error so consumers can dispatch without parsing the message.
- OTel emission on every public op — six counters partitioned by `outcome` (`success` | `permission_denied` | `access_failed`) and two histograms.
- `sync()` never throws — failures land in `SyncResult.errors`.
- `isAvailable()` never throws — runner exceptions are swallowed and reported as `false`.
## Usage: Basic
```ts
import { MacOSContactsBridge } from "@suaveplan/macos-contacts";
const bridge = new MacOSContactsBridge({ timeoutMs: 30_000 });
if (await bridge.isAvailable()) {
const contacts = await bridge.getAllContacts();
console.log(`${contacts.length} contacts`);
}
```
## Usage: Advanced
```ts
import {
MacOSContactsBridge,
MacOSContactsError,
type AppleScriptRunner,
} from "@suaveplan/macos-contacts";
// Inject a fake runner for tests / non-macOS environments.
const fakeRunner: AppleScriptRunner = {
async run() {
return "id|||A|||B||| ||| ||| |||false\nPHONES:\nEMAILS:\nADDRESSES:";
},
};
const bridge = new MacOSContactsBridge(
{ timeoutMs: 5_000 },
{ runner: fakeRunner, clock: () => 1_700_000_000_000 },
);
try {
const result = await bridge.sync();
if (result.errors.length > 0) {
for (const err of result.errors) {
if (err.code === "MACOS_CONTACTS.CONTACTS_PERMISSION_DENIED") {
showPermissionPrompt();
}
}
}
} catch (err) {
if (err instanceof MacOSContactsError) {
// sync itself never throws, but other methods may.
}
}
```
## API Reference
### `new MacOSContactsBridge(config?, deps?)`
- `config?.timeoutMs?: number` — per-invocation timeout in ms. Defaults to 30 000. Must be a positive integer when supplied.
- `config?.batchSize?: number` — reserved for future batched paths. Defaults to 500.
- `deps?.runner?: AppleScriptRunner` — injectable runner seam. Defaults to `createDefaultAppleScriptRunner({ timeoutMs })`.
- `deps?.clock?: Clock` — injectable time source. Defaults to `() => Date.now()`.
### `bridge.isAvailable(): Promise<boolean>`
Probes the runner with a harmless `System Events` script. Returns `true` when stdout is non-empty, `false` otherwise. Never throws.
### `bridge.getAllContacts(): Promise<MacOSContact[]>`
Invokes `getAllContactsScript()`, parses stdout via `parseContacts`. Runner failures raise `MacOSContactsError`.
### `bridge.searchContacts(query): Promise<MacOSContact[]>`
Empty / whitespace `query` short-circuits to `[]`. Otherwise invokes `searchContactsScript(query)`. Failures attach the `query` discriminant.
### `bridge.getContactById(id): Promise<MacOSContact | null>`
Empty / whitespace `id` short-circuits to `null`. Otherwise calls `getAllContacts()` and returns the first match or `null`. Failures attach the `contactId` discriminant.
### `bridge.getGroups(): Promise<ContactGroup[]>`
Invokes `getGroupsScript()`, parses stdout via `parseGroups`.
### `bridge.getGroupMembers(groupName): Promise<MacOSContact[]>`
Empty / whitespace `groupName` short-circuits to `[]`. Failures attach the `groupName` discriminant.
### `bridge.sync(): Promise<SyncResult>`
Calls `getAllContacts()` and captures any failure in `SyncResult.errors`. `syncedAt` is built from the injected clock; the call also records to the `macos_contacts.sync_duration_ms` histogram.
## Implementation Notes
The bridge intentionally keeps the runner ignorant of `MacOSContactsError`. The runner contract is "run this AppleScript, resolve with stdout, reject with `Error` on any failure"; the bridge layers the typed error mapping on top so the test surface is small (one fake runner per test, no error-mapping logic to mock).
Error-mapping uses a single shared regex (`PERMISSION_PATTERN`) so the policy lives in one place. `classify(cause)` returns the `outcome` attribute for OTel; `toBridgeError(cause, discriminants)` constructs the typed error. Both call sites stay synchronous so the catch arms don't accidentally swallow rejections from instrumentation.
`isAvailable()` and `sync()` are the only methods that catch and swallow exceptions. Every other method propagates failures through the typed error so callers can dispatch on `error.code` without inspecting strings.
The histogram contract is "duration of the script invocation, in milliseconds". The bridge wraps the runner call with `runWithHistogram` so the value is recorded even if the runner rejects — observability survives even when the underlying call fails. `Math.max(0, …)` guards against clock skew producing negative samples.

View File

@@ -0,0 +1,89 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry and typed `MacOSContactsError` class for `@suaveplan/macos-contacts`. Every failure path the package exposes raises a `MacOSContactsError` carrying one of six registered codes (`INVALID_INPUT`, `CONTACTS_NOT_FOUND`, `CONTACTS_PERMISSION_DENIED`, `CONTACTS_ACCESS_FAILED`, `CONTACTS_PARSE_FAILED`, `CONTACTS_UNAVAILABLE`). Codes are namespaced strings (`"MACOS_CONTACTS.CONTACTS_PERMISSION_DENIED"`) so consumers can dispatch programmatically without parsing messages, and the typed error narrows the `query` / `groupName` / `contactId` discriminants for diagnostics.
## Features
- `MACOS_CONTACTS_CODES` — `defineCodes("MACOS_CONTACTS", [...])` registry; the canonical source of truth for every code emitted by the package.
- `MacOSContactsError extends SuaveplanError<MacOSContactsErrorCode>` — typed error class. `instanceof` narrows `code` to a literal-string union.
- `MacOSContactsErrorCode` — union of every registered code.
- `MacOSContactsErrorOptions` — options bag extending `SuaveplanErrorOptions` with `query`, `groupName`, `contactId` discriminants and standard `cause` chaining.
- Replaces upstream's `HttpError(... code as unknown as HttpErrorCode)` cast: codes flow through the type system end-to-end, with no `unknown` escape hatches.
## Usage: Basic
```ts
import {
MACOS_CONTACTS_CODES,
MacOSContactsError,
} from "@suaveplan/macos-contacts";
function ensurePermission(stderr: string) {
if (/not authorized/i.test(stderr)) {
throw new MacOSContactsError(
MACOS_CONTACTS_CODES.CONTACTS_PERMISSION_DENIED,
"macOS denied access to Contacts.app",
);
}
}
```
## Usage: Advanced
```ts
import {
MACOS_CONTACTS_CODES,
MacOSContactsError,
} from "@suaveplan/macos-contacts";
try {
await bridge.searchContacts(query);
} catch (err) {
if (err instanceof MacOSContactsError) {
switch (err.code) {
case MACOS_CONTACTS_CODES.CONTACTS_PERMISSION_DENIED:
return openSystemSettingsDialog();
case MACOS_CONTACTS_CODES.INVALID_INPUT:
return showValidationToast(err.message);
default:
throw err;
}
}
throw err;
}
```
## API Reference
### `MACOS_CONTACTS_CODES`
A frozen registry produced by `defineCodes("MACOS_CONTACTS", [...] as const)`. Each entry maps a bare key (e.g. `CONTACTS_PERMISSION_DENIED`) to its namespaced string (`"MACOS_CONTACTS.CONTACTS_PERMISSION_DENIED"`).
### `MacOSContactsErrorCode`
Union type `(typeof MACOS_CONTACTS_CODES)[keyof typeof MACOS_CONTACTS_CODES]`. Use to type-check `error.code` in catch blocks.
### `MacOSContactsError(code, message, options?)`
Constructor parameters:
- `code: MacOSContactsErrorCode` — required, the discriminant.
- `message: string` — required, English diagnostic.
- `options.query?: string` — attaches `error.query`.
- `options.groupName?: string` — attaches `error.groupName`.
- `options.contactId?: string` — attaches `error.contactId`.
- `options.cause?: unknown` — standard ES `Error.cause`; preserved through serialization and span events.
## Implementation Notes
The discriminants live as direct instance properties so consumers can do `if (err.query)` without an extra unwrap. Codes are validated at registry time by `defineCodes`, which freezes the result and enforces unique namespace tokens.
Adding a new code requires three concurrent edits: append the literal to the `MACOS_CONTACTS_CODES` array, raise it from at least one production call site, and add a test that round-trips an instance with the new code. The package's own `macos-contacts.spec-conformance.test.ts` enforces the "every code referenced" rule — removing a code to placate a gate is banned per `AGENTS.md` §2c.
Error-code strings are part of the public API. Renaming one is a breaking change. Add a new code instead and let consumers migrate explicitly.

View File

@@ -0,0 +1,81 @@
# executor
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `AppleScriptRunner` injectable seam that `MacOSContactsBridge` depends on, plus a default implementation backed by `node:child_process` against the platform `osascript` binary. The seam exists so tests run on any platform without macOS — inject a fake runner that returns fixture strings and the entire bridge can be exercised on Linux CI, Bun on WSL, browser-style runtimes that polyfill child_process, etc.
## Features
- `AppleScriptRunner` interface — single method `run(script, signal?)` returning the script's stdout as a string.
- `createDefaultAppleScriptRunner({ timeoutMs, maxBufferBytes?, binary? })` — produces a runner that calls `execFile` (argv-based, no shell interpolation) on the configured binary (default `"osascript"`), applies a per-invocation timeout, optionally caps stdout, and threads `AbortSignal` through so callers can cancel an in-flight invocation.
- `DefaultAppleScriptRunnerOptions` — public options bag for the default factory.
- No mocking framework required by consumers — the seam is the interface; tests provide fakes by `implements`-ing it.
## Usage: Basic
```ts
import { createDefaultAppleScriptRunner } from "@suaveplan/macos-contacts";
const runner = createDefaultAppleScriptRunner({ timeoutMs: 30_000 });
const stdout = await runner.run('return "hello"');
```
## Usage: Advanced
```ts
import type { AppleScriptRunner } from "@suaveplan/macos-contacts";
import { MacOSContactsBridge } from "@suaveplan/macos-contacts";
// Test-only fake — no macOS / no shell required.
const fakeRunner: AppleScriptRunner = {
async run(script) {
if (script.includes("every person")) {
return "id1|||A|||B||| ||| ||| |||false\nPHONES:\nEMAILS:\nADDRESSES:";
}
throw new Error("Unexpected script");
},
};
const bridge = new MacOSContactsBridge({ timeoutMs: 1000 }, { runner: fakeRunner });
const contacts = await bridge.getAllContacts();
// In production: pass an AbortSignal to cancel a slow invocation.
const controller = new AbortController();
const real = createDefaultAppleScriptRunner({ timeoutMs: 60_000 });
const promise = real.run(largeScript, controller.signal);
setTimeout(() => controller.abort(), 5_000);
```
## API Reference
### `AppleScriptRunner`
```ts
interface AppleScriptRunner {
run(script: string, signal?: AbortSignal): Promise<string>;
}
```
Implementations SHALL resolve with stdout on success and reject with an `Error` on failure (non-zero exit, timeout, abort, missing binary). The bridge maps rejections through its typed error-mapping rule; runner implementations SHOULD NOT wrap their rejection in `MacOSContactsError` themselves — leave that to the bridge.
### `DefaultAppleScriptRunnerOptions`
- `timeoutMs: number` — required. Applied to every invocation.
- `maxBufferBytes?: number` — optional. Defaults to 10 MiB.
- `binary?: string` — optional. Defaults to `"osascript"`. Override only for test harnesses (e.g. `"/bin/false"` to assert the failure path).
### `createDefaultAppleScriptRunner(options): AppleScriptRunner`
Returns a runner that wraps `node:child_process` via the argv-based file-execution path. The argument vector is fixed at `[binary, "-e", script]` so the AppleScript payload is never subject to shell interpolation. Honours the supplied `AbortSignal`.
## Implementation Notes
The runner uses the argv-based file-execution path rather than the shell-string path, so the AppleScript payload is never passed through `/bin/sh` and is therefore not subject to injection from quotes or backslashes inside the script body. This means template builders only need to escape `"` for AppleScript itself, not for any intermediate shell layer.
`AbortSignal` is forwarded directly to the child via the options bag; an already-aborted signal causes the child to be killed immediately and the returned promise to reject. The bridge does not currently surface this through its public API, but downstream callers that compose the runner directly can rely on cancellation working.
The `binary` override exists for tests: pointing at `"/bin/false"` exercises the rejection path without requiring `osascript`, and pointing at `"/bin/echo"` exercises the resolution path on any POSIX runner. Production callers should leave it at the default.

View File

@@ -0,0 +1,96 @@
# instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Bundle of OpenTelemetry instruments emitted by `@suaveplan/macos-contacts`. The accessor `getMacOSContactsInstruments()` lazily binds against the current global `MeterProvider`, caches the bundle, and returns the same object on every subsequent call. Tests call `resetMacOSContactsInstruments()` after installing a fresh `MeterProvider` to force a rebind; production callers never need to touch the reset path.
## Features
- Six counters partitioned by `outcome` (`success` | `permission_denied` | `access_failed`):
- `macos_contacts.list_total` — `MacOSContactsBridge.getAllContacts`
- `macos_contacts.search_total` — `searchContacts`
- `macos_contacts.get_total` — `getContactById`
- `macos_contacts.groups_list_total` — `getGroups`
- `macos_contacts.group_members_total` — `getGroupMembers`
- `macos_contacts.sync_total` — `sync`
- Two histograms (unit: ms):
- `macos_contacts.applescript_duration_ms` — wraps every runner invocation
- `macos_contacts.sync_duration_ms` — wraps `sync` end-to-end
- Lazy + cached — the bundle is built on first access against the global `MeterProvider`. Subsequent calls return the same reference (`===`-equal).
## Usage: Basic
```ts
import {
getMacOSContactsInstruments,
} from "@suaveplan/macos-contacts";
const instruments = getMacOSContactsInstruments();
instruments.listTotal.add(1, { outcome: "success" });
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import {
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import {
getMacOSContactsInstruments,
resetMacOSContactsInstruments,
} from "@suaveplan/macos-contacts";
// Wire a real in-memory MeterProvider for tests.
const exporter = new InMemoryMetricExporter(0);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.disable();
metrics.setGlobalMeterProvider(provider);
// Force the bundle to rebind against the new provider.
resetMacOSContactsInstruments();
const instruments = getMacOSContactsInstruments();
```
## API Reference
### `MacOSContactsInstruments`
```ts
interface MacOSContactsInstruments {
readonly listTotal: Counter;
readonly searchTotal: Counter;
readonly getTotal: Counter;
readonly groupsListTotal: Counter;
readonly groupMembersTotal: Counter;
readonly syncTotal: Counter;
readonly applescriptDurationMs: Histogram;
readonly syncDurationMs: Histogram;
}
```
### `getMacOSContactsInstruments(): MacOSContactsInstruments`
Lazily builds and caches the bundle. Returns the same object reference on every subsequent call until `resetMacOSContactsInstruments()` is called.
### `resetMacOSContactsInstruments(): void`
Drops the cached bundle. The next `getMacOSContactsInstruments()` call rebuilds it against whatever global `MeterProvider` is in effect.
## Implementation Notes
The bundle is built against the global `MeterProvider` rather than a parameter so consumers do not need to thread a meter through every layer. Tests reset the cache after installing a new provider so each test sees a fresh instrument set bound to its own in-memory exporter.
Counters partition by `outcome` so dashboards can distinguish revoked-permission incidents from transient `osascript` failures. Histograms record duration in milliseconds (`unit: "ms"`); the bridge wraps the runner call so the sample is recorded even when the runner rejects — observability survives platform-level failures.
Instrument names use `snake_case` per OpenTelemetry semantic conventions. The `macos_contacts.` prefix mirrors the package name so multi-package dashboards can filter by namespace.

View File

@@ -0,0 +1,71 @@
# parser
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pure free-function parser for the raw text stream emitted by the AppleScript templates that drive `MacOSContactsBridge`. Upstream `@suaveplan/macos-contacts` shipped this surface as a static-only class with a `// biome-ignore lint/complexity/noStaticOnlyClass` suppression — Ships it as three plain functions (`parseContacts`, `parseGroups`, `normalizePhone`) so no suppression is needed and tree-shaking is finer-grained.
## Features
- `parseContacts(raw)` — split on `---CONTACT---`, parse each block into a `MacOSContact`, skip malformed blocks silently.
- `parseGroups(raw)` — line-oriented, splits each line on `|||`, skips malformed lines silently.
- `normalizePhone(phone)` — strips non-digit characters except a leading `+`; defaults bare 10-digit numbers to the NANP (`+1`) prefix; returns `""` for empty / non-digit input.
- No side effects, no module-level state, no `Math.random()` / `Date.now()` — every function is referentially transparent.
## Usage: Basic
```ts
import { parseContacts } from "@suaveplan/macos-contacts";
const raw = "abc|||John|||Doe||| ||| ||| |||false\nPHONES:\nEMAILS:\nADDRESSES:";
const contacts = parseContacts(raw);
// contacts[0].firstName === "John"
```
## Usage: Advanced
```ts
import { normalizePhone, parseContacts } from "@suaveplan/macos-contacts";
async function importContactsFile(text: string) {
const contacts = parseContacts(text);
return contacts.map((c) => ({
...c,
phoneNumbers: c.phoneNumbers.map((p) => ({
...p,
normalised: normalizePhone(p.number),
})),
}));
}
```
## API Reference
### `parseContacts(rawOutput: string): MacOSContact[]`
Splits `rawOutput` on the `---CONTACT---` delimiter, parses each block. A block has one header line (`id|||first|||last|||org|||birthday|||notes|||imageAvailable`) followed by zero or more section lines (`PHONES:`, `EMAILS:`, `ADDRESSES:`). Section items are delimited by `;;;` and fields within an item by `:`. Empty input returns `[]`; malformed blocks (header missing 7 fields, body missing entirely) are silently dropped.
### `parseGroups(rawOutput: string): ContactGroup[]`
Splits `rawOutput` on newlines, then each line on `|||`. Lines with fewer than 3 fields are dropped. `memberCount` falls back to `0` when the third field is not numeric.
### `normalizePhone(phone: string): string`
Trims, strips non-digit characters (except a leading `+`), and:
- returns `""` for empty / whitespace-only input,
- returns `""` for input with zero digits after stripping,
- preserves the `+` prefix when present,
- prepends `+1` when the digit count is exactly 10 (NANP default),
- otherwise prepends `+`.
## Implementation Notes
The parser is intentionally tolerant: AppleScript output can vary slightly between macOS versions, and the bridge would rather drop one malformed contact than reject the whole address book. The malformed-block path is exercised by the test `parseContacts skips malformed contact blocks`.
Switching from the static class to free functions is a public-API change relative to upstream — callers that wrote `ContactParser.parse(raw)` migrate to `parseContacts(raw)`. The new surface is exported both from the package root and from the `./parser/parser` subpath so consumers can tree-shake at function granularity.
The internal `parseContactBlock` / `parsePhones` / `parseEmails` / `parseAddresses` helpers are not exported. They are pure, total, and structured so the test suite covers every branch (empty section, malformed item, all-fields-populated).

View File

@@ -0,0 +1,84 @@
# schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas guarding every public input boundary of `@suaveplan/macos-contacts`. Validation runs before any side effect; failures raise a typed `MacOSContactsError(INVALID_INPUT)` with the underlying `ZodError` chained as `cause` and the failing field paths embedded in the message. This pattern keeps consumers from having to import `zod` to dispatch on validation errors, and keeps the bridge's `try { runner.run(...) }` blocks focused on platform failures, not shape failures.
## Features
- `macOSContactsConfigSchema` — validates the optional `{ timeoutMs, batchSize }` bundle accepted by `MacOSContactsBridge`.
- `searchQuerySchema` — non-empty trimmed string; used by `searchContacts`.
- `groupNameSchema` — non-empty trimmed string; used by `getGroupMembers`.
- `contactIdSchema` — non-empty trimmed string; used by `getContactById`.
- `parseOrThrow(schema, input, label)` — generic helper. Returns the parsed value on success, throws `MacOSContactsError(INVALID_INPUT)` on failure.
## Usage: Basic
```ts
import {
macOSContactsConfigSchema,
parseOrThrow,
} from "@suaveplan/macos-contacts";
const config = parseOrThrow(
macOSContactsConfigSchema,
{ timeoutMs: 30_000 },
"myCaller",
);
```
## Usage: Advanced
```ts
import z from "zod";
import {
MacOSContactsError,
parseOrThrow,
searchQuerySchema,
} from "@suaveplan/macos-contacts";
const richQuerySchema = z.object({
query: searchQuerySchema,
limit: z.number().int().min(1).max(500),
});
try {
const parsed = parseOrThrow(richQuerySchema, input, "rich.search");
await bridge.searchContacts(parsed.query);
} catch (err) {
if (err instanceof MacOSContactsError && err.code === "MACOS_CONTACTS.INVALID_INPUT") {
return form.showValidation(err.message);
}
throw err;
}
```
## API Reference
### `macOSContactsConfigSchema`
`z.object({ timeoutMs: z.number().int().positive().optional(), batchSize: z.number().int().positive().optional() })`. Both fields are optional; defaults are filled in by `MacOSContactsBridge`.
### `searchQuerySchema` / `groupNameSchema` / `contactIdSchema`
`z.string().trim().min(1)`. Each schema rejects empty / whitespace-only input.
### `parseOrThrow<T>(schema: z.ZodType<T>, input: unknown, label: string): T`
Runs `schema.safeParse(input)`. On success returns the parsed value (with whatever transformations the schema applied, e.g. `.trim()`). On failure constructs a `MacOSContactsError` with:
- `code === "MACOS_CONTACTS.INVALID_INPUT"`,
- `message === "<label> input failed validation: <field>: <issue>; <field>: <issue>"`,
- `cause === <ZodError>`.
The `label` argument is the human-readable name of the call site (e.g. `"MacOSContactsBridge.constructor"`); it shows up first in the message so log readers can tell which boundary failed.
## Implementation Notes
The `parseOrThrow` helper is intentionally generic over the schema's output type so callers do not lose Zod's type inference. The error message lists every failing path so consumers can render granular validation feedback without re-running the parser.
Schemas live in their own module rather than being co-located with the bridge so unit tests can exercise schema-level invariants (empty query rejection, positive `timeoutMs` requirement) without instantiating the bridge. The bridge itself calls `parseOrThrow` exclusively — it never reaches for `schema.parse` or `schema.safeParse` directly, which keeps the error-mapping policy in one place.

View File

@@ -0,0 +1,91 @@
# macos-contacts.types
## Purpose
Entity types for `@suaveplan/macos-contacts`. Shapes are preserved verbatim from upstream `../suaveplan/packages/universal/macos-contacts/src/types/macos-contacts.types.ts` so consumers migrating from main see no semantic drift on the data surface; only the wiring layer (errors, OTel, runner seam) was rewritten for suaveplan. Every field is `readonly` to make the bridge's return values immutable by default — the bridge composes new objects on each invocation, never mutates a cached record.
## Features
- `ContactPhone` — label + number pair.
- `ContactEmail` — label + address pair.
- `ContactAddress` — label plus optional structured fields (street / city / state / postalCode / country).
- `MacOSContact` — the full contact record, including arrays of phones / emails / postal addresses.
- `ContactGroup` — group id + name + member count.
- `MacOSContactsConfig` — `timeoutMs` / `batchSize` knobs for the bridge.
- `SyncError` — single error entry on a `SyncResult`.
- `SyncResult` — contacts + timestamp + total + errors bundle returned by `bridge.sync()`.
## Usage: Basic
```ts
import type { MacOSContact } from "@suaveplan/macos-contacts";
function fullName(contact: MacOSContact): string {
return `${contact.firstName} ${contact.lastName}`.trim();
}
```
## Usage: Advanced
```ts
import type {
ContactGroup,
MacOSContact,
SyncResult,
} from "@suaveplan/macos-contacts";
function summarise(result: SyncResult, groups: readonly ContactGroup[]) {
return {
contactCount: result.total,
firstNames: result.contacts.map((c: MacOSContact) => c.firstName),
groupNames: groups.map((g) => g.name),
syncedAt: result.syncedAt.toISOString(),
errorCodes: result.errors.map((e) => e.code),
};
}
```
## API Reference
### `ContactPhone`
`{ readonly label: string; readonly number: string }`. `label` is the AppleScript-reported label string (`"mobile"`, `"home"`, `"work"`, …). `number` is verbatim — call `normalizePhone` from the `parser` module to convert to E.164 form.
### `ContactEmail`
`{ readonly label: string; readonly address: string }`.
### `ContactAddress`
`{ readonly label: string; readonly street?: string; readonly city?: string; readonly state?: string; readonly postalCode?: string; readonly country?: string }`. Every structured field is optional; the parser omits whichever fields were blank in the AppleScript output.
### `MacOSContact`
The full contact record:
- `id` — opaque AppleScript identifier (stable across queries on the same machine).
- `firstName`, `lastName` — raw strings; may be empty.
- `organization`, `birthday`, `notes` — optional; absent when blank.
- `phoneNumbers`, `emailAddresses` — readonly arrays (may be empty).
- `postalAddresses` — optional readonly array; absent when the contact has no addresses.
- `imageAvailable` — boolean computed from `image of person is not missing value`.
### `ContactGroup`
`{ readonly id: string; readonly name: string; readonly memberCount: number }`.
### `MacOSContactsConfig`
`{ readonly timeoutMs?: number; readonly batchSize?: number }`. Both fields are optional; bridge defaults are 30 000 ms and 500 entries respectively.
### `SyncError` / `SyncResult`
`SyncError` carries `code` (one of the `MACOS_CONTACTS.*` strings), `message`, and an optional `contactId`. `SyncResult` carries `contacts` (readonly array), `syncedAt` (`Date` built from the injected `Clock`), `total` (mirrors `contacts.length`), and `errors` (readonly array). `bridge.sync()` always returns a `SyncResult` — failures land in `errors`, not as thrown exceptions.
## Implementation Notes
The types are intentionally `readonly` on every field. This signals "the bridge owns these values and may rebuild them across invocations". Consumers who need to mutate (e.g. to add a tag) clone via spread first — the spread is cheap because the underlying values are primitives or small readonly arrays.
`MacOSContact.id` is opaque — do not parse it for structural information. Apple changes the format between macOS versions; treating it as a string key (for `Map`, `Set`, URL paths) is safe.
The `Date` carried in `SyncResult.syncedAt` is constructed via `new Date(clock())` so it reflects the injected `Clock` for deterministic tests. Production callers pass `Date.now()` (the default) and receive a real wall-clock timestamp.