first commit
This commit is contained in:
16
docs/commerce/contacts/README.md
Normal file
16
docs/commerce/contacts/README.md
Normal 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 -->
|
||||
1
docs/commerce/contacts/_intro.md
Normal file
1
docs/commerce/contacts/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Contact and group management with deduplication and import/export.
|
||||
126
docs/commerce/contacts/api-plugin-contacts/README.md
Normal file
126
docs/commerce/contacts/api-plugin-contacts/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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".
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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(...)`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
111
docs/commerce/contacts/audience-rules/README.md
Normal file
111
docs/commerce/contacts/audience-rules/README.md
Normal 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`.
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
96
docs/commerce/contacts/audience-rules/src/schemas/schemas.md
Normal file
96
docs/commerce/contacts/audience-rules/src/schemas/schemas.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
93
docs/commerce/contacts/audience-rules/src/store/store.md
Normal file
93
docs/commerce/contacts/audience-rules/src/store/store.md
Normal 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`.
|
||||
105
docs/commerce/contacts/audience-rules/src/types/segment.types.md
Normal file
105
docs/commerce/contacts/audience-rules/src/types/segment.types.md
Normal 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.
|
||||
120
docs/commerce/contacts/contacts/README.md
Normal file
120
docs/commerce/contacts/contacts/README.md
Normal 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) |
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
86
docs/commerce/contacts/contacts/src/otel/instruments.md
Normal file
86
docs/commerce/contacts/contacts/src/otel/instruments.md
Normal 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).
|
||||
93
docs/commerce/contacts/contacts/src/schemas/schemas.md
Normal file
93
docs/commerce/contacts/contacts/src/schemas/schemas.md
Normal 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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
94
docs/commerce/contacts/contacts/src/tree/tree-utils.md
Normal file
94
docs/commerce/contacts/contacts/src/tree/tree-utils.md
Normal 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.
|
||||
85
docs/commerce/contacts/contacts/src/types/contact.types.md
Normal file
85
docs/commerce/contacts/contacts/src/types/contact.types.md
Normal 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`.
|
||||
74
docs/commerce/contacts/contacts/src/types/group.types.md
Normal file
74
docs/commerce/contacts/contacts/src/types/group.types.md
Normal 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.
|
||||
@@ -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.
|
||||
61
docs/commerce/contacts/macos-contacts/README.md
Normal file
61
docs/commerce/contacts/macos-contacts/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
115
docs/commerce/contacts/macos-contacts/src/bridge/bridge.md
Normal file
115
docs/commerce/contacts/macos-contacts/src/bridge/bridge.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
71
docs/commerce/contacts/macos-contacts/src/parser/parser.md
Normal file
71
docs/commerce/contacts/macos-contacts/src/parser/parser.md
Normal 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).
|
||||
84
docs/commerce/contacts/macos-contacts/src/schemas/schemas.md
Normal file
84
docs/commerce/contacts/macos-contacts/src/schemas/schemas.md
Normal 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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user