first commit
This commit is contained in:
94
docs/communication/emails/campaigns/README.md
Normal file
94
docs/communication/emails/campaigns/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# @suaveplan/campaigns - Universal campaign orchestration — engine, state machine, batch planner, workflow, and pluggable store with injectable Clock/IdGenerator/TimersLike/LoggerLike, CAMPAIGNS_CODES error registry, and OTel self-telemetry
|
||||
|
||||
Universal campaign orchestration.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [batch-planner](./src/batch-planner/batch-planner.md) — Deterministic batch-planning engine for campaign sends. Splits a contact list into sized batches with throttle-aware sc…
|
||||
- [batch-planner](./src/batch-planner/batch-planner.md) — Deterministic batch-planning engine for campaign sends. Splits a contact list into sized batches with throttle-aware sc…
|
||||
- [engine](./src/engine/engine.md) — Top-level orchestrator for campaign CRUD and lifecycle. `CampaignEngine` accepts an injectable `CampaignStore` and expo…
|
||||
- [engine](./src/engine/engine.md) — Top-level orchestrator for campaign CRUD and lifecycle. `CampaignEngine` accepts an injectable `CampaignStore` and expo…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, machine-readable error namespace for `@suaveplan/campaigns`. Every failure path in the package — fr…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, machine-readable error namespace for `@suaveplan/campaigns`. Every failure path in the package — fr…
|
||||
- [state-machine](./src/state-machine/state-machine.md) — Strictly typed lifecycle state machine for campaigns. Owns the set of nine legal transitions between the seven `Campaig…
|
||||
- [state-machine](./src/state-machine/state-machine.md) — Strictly typed lifecycle state machine for campaigns. Owns the set of nine legal transitions between the seven `Campaig…
|
||||
- [store](./src/store/store.md) — Defines the `CampaignStore` persistence contract and ships an `InMemoryCampaignStore` reference implementation. Every s…
|
||||
- [store](./src/store/store.md) — Defines the `CampaignStore` persistence contract and ships an `InMemoryCampaignStore` reference implementation. Every s…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — OTel self-telemetry for `@suaveplan/campaigns`. Exposes a small set of counters and histograms that are emitted from ev…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — OTel self-telemetry for `@suaveplan/campaigns`. Exposes a small set of counters and histograms that are emitted from ev…
|
||||
- [workflow](./src/workflow/workflow.md) — Composable multi-step send pipeline for campaigns. `CampaignWorkflow.buildSendPipeline` returns a canonical six-step pi…
|
||||
- [workflow](./src/workflow/workflow.md) — Composable multi-step send pipeline for campaigns. `CampaignWorkflow.buildSendPipeline` returns a canonical six-step pi…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Modules
|
||||
|
||||
- [`engine/`](./src/engine/engine.md) — `CampaignEngine`: CRUD + lifecycle methods over an injectable `CampaignStore`.
|
||||
- [`state-machine/`](./src/state-machine/state-machine.md) — `CampaignStateMachine`: nine canonical transitions, typed event union.
|
||||
- [`batch-planner/`](./src/batch-planner/batch-planner.md) — `BatchPlanner`: throttle-aware splitter + duration estimator.
|
||||
- [`workflow/`](./src/workflow/workflow.md) — `CampaignWorkflow`: composable 6-step send pipeline + sequential runner.
|
||||
- [`store/`](./src/store/store.md) — `CampaignStore` interface + `InMemoryCampaignStore` reference.
|
||||
- [`error-codes/`](./src/error-codes/error-codes.md) — `CAMPAIGNS_CODES` + `CampaignsError`.
|
||||
- [`telemetry/`](./src/telemetry/telemetry.md) — OTel meter, counters, histograms.
|
||||
- [`types/`](./src/types/) — shared types.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/campaigns
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { CampaignEngine, InMemoryCampaignStore } from "@suaveplan/campaigns";
|
||||
|
||||
const engine = new CampaignEngine(new InMemoryCampaignStore());
|
||||
|
||||
const campaign = await engine.create({
|
||||
tenantId: "tenant-1",
|
||||
name: "Spring Sale Announcement",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: {
|
||||
groupIds: ["all-subscribers"],
|
||||
excludeGroupIds: ["unsubscribed"],
|
||||
},
|
||||
templateId: "spring-sale-v1",
|
||||
});
|
||||
|
||||
await engine.schedule(campaign.id, {
|
||||
type: "scheduled",
|
||||
sendAt: new Date("2026-05-01T09:00:00Z"),
|
||||
timezone: "America/New_York",
|
||||
});
|
||||
|
||||
await engine.send(campaign.id);
|
||||
```
|
||||
|
||||
## Throttle-aware batch planning
|
||||
|
||||
```ts
|
||||
import { BatchPlanner, type ThrottleConfig } from "@suaveplan/campaigns";
|
||||
|
||||
const planner = new BatchPlanner();
|
||||
const throttle: ThrottleConfig = { messagesPerSecond: 50 };
|
||||
const batches = planner.planBatches(contactIds, { batchSize: 200 }, throttle);
|
||||
const etaMs = planner.calculateTotalDuration(contactIds.length, { batchSize: 200 }, throttle);
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
CampaignEngine ──┬── CampaignStateMachine (validation)
|
||||
├── CampaignStore (persistence)
|
||||
├── BatchPlanner (throttle-aware planning)
|
||||
└── CampaignWorkflow (6-step pipeline)
|
||||
```
|
||||
|
||||
All callers see `CampaignsError` with codes from `CAMPAIGNS_CODES`. All operations record OTel counters/histograms via the `telemetry/` module.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# batch-planner
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Deterministic batch-planning engine for campaign sends. Splits a contact list into sized batches with throttle-aware scheduled timestamps, so a downstream dispatcher can read each `Batch.scheduledAt` and either dispatch immediately (when `undefined`) or schedule a delayed dispatch. The planner is the single source of truth for "how fast can we send this campaign without violating throttle limits", which keeps dispatch loops and reporting consistent.
|
||||
|
||||
The planner takes an injectable `Clock` so its output is reproducible in tests — pinning `t=0` to a fixed wall-clock value lets us assert exact timestamps without flaky `Date.now()` reads.
|
||||
|
||||
## Features
|
||||
|
||||
- `planBatches(contactIds, config?, throttle?)` returns a `Batch[]` keyed by `batchIndex`.
|
||||
- Throttle-aware delay calculation: picks the most restrictive of `messagesPerSecond`, `messagesPerMinute`, `messagesPerHour`, falling back to `delayBetweenBatches`.
|
||||
- Empty input short-circuits to `[]` while still recording the 0-size plan in telemetry.
|
||||
- `calculateTotalDuration(totalContacts, config?, throttle?)` estimates the total send-window length in milliseconds.
|
||||
- All inputs (`BatchPlannerOptions`, `BatchConfig`, `ThrottleConfig`) are Zod-validated at the boundary; bad values throw `CampaignsError(INVALID_BATCH_CONFIG|INVALID_THROTTLE_CONFIG|INVALID_OPTIONS)`.
|
||||
- Emits `campaigns_batches_planned_total`, `campaigns_batch_plan_size`, and `campaigns_send_duration_ms` instruments.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { BatchPlanner } from "@suaveplan/campaigns";
|
||||
|
||||
const planner = new BatchPlanner();
|
||||
const batches = planner.planBatches(contactIds, { batchSize: 200 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pin the clock for reproducible tests and use the duration estimator to drive ETA labels:
|
||||
|
||||
```ts
|
||||
import { BatchPlanner, type ThrottleConfig } from "@suaveplan/campaigns";
|
||||
|
||||
const planner = new BatchPlanner({ clock: () => 1_700_000_000_000 });
|
||||
const throttle: ThrottleConfig = { messagesPerSecond: 50 };
|
||||
const batches = planner.planBatches(contactIds, { batchSize: 250 }, throttle);
|
||||
const etaMs = planner.calculateTotalDuration(contactIds.length, { batchSize: 250 }, throttle);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `BatchPlannerOptions` — `{ clock?: Clock }`.
|
||||
- `Batch` — `{ batchIndex; contactIds; scheduledAt? }`.
|
||||
- `BatchPlanner.planBatches(contactIds, config?, throttle?): Batch[]`.
|
||||
- `BatchPlanner.calculateTotalDuration(totalContacts, config?, throttle?): number`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The first batch (`batchIndex === 0`) always has `scheduledAt === undefined`, signalling "send immediately". Each subsequent batch's `scheduledAt` is `clock() + batchIndex * effectiveDelay`. `resolveDelay` deliberately reads only the first set throttle field (`messagesPerSecond` wins over `Minute`, which wins over `Hour`) — combining them would require a more sophisticated model and is left for an OpenSpec change. The duration estimator returns `0` for zero or negative contact counts so it can be called speculatively from UI layers without guard rails.
|
||||
60
docs/communication/emails/campaigns/src/engine/engine.md
Normal file
60
docs/communication/emails/campaigns/src/engine/engine.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# engine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Top-level orchestrator for campaign CRUD and lifecycle. `CampaignEngine` accepts an injectable `CampaignStore` and exposes one method per high-level operation — `create`, `schedule`, `send`, `pause`, `resume`, `cancel`, `complete`, `fail`, `duplicate`, `updateStats`, `getById`, `list`. Lifecycle methods delegate validation to `CampaignStateMachine`; persistence delegates to the store. The engine itself contains no transition rules and no storage details, which keeps it stable across plugins (in-memory, SurrealDB, durable-object) and across UI surfaces.
|
||||
|
||||
All inputs cross a Zod boundary before they touch the store, so bad payloads surface as `CampaignsError(INVALID_INPUT|INVALID_OPTIONS)` rather than leaking through to the persistence layer.
|
||||
|
||||
## Features
|
||||
|
||||
- Injectable `Clock`, `IdGenerator`, and `LoggerLike` so the engine is deterministic under test.
|
||||
- Zod validation on construction options and `CreateCampaignInput`.
|
||||
- Delegates state validation to `CampaignStateMachine`, never duplicates rules.
|
||||
- Records OTel instruments on every meaningful event (created / sent / failed / duplicate).
|
||||
- `duplicate(id)` produces a draft copy with reset stats, blanked `sentAt`/`completedAt`, and the original name suffixed with `(copy)`.
|
||||
- `fail(id, reason?)` stores the optional reason under `metadata.failureReason` before transitioning to the terminal `failed` state.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CampaignEngine, InMemoryCampaignStore } from "@suaveplan/campaigns";
|
||||
|
||||
const engine = new CampaignEngine(new InMemoryCampaignStore());
|
||||
const campaign = await engine.create({
|
||||
tenantId: "t-1",
|
||||
name: "Spring Sale",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: { groupIds: ["subscribers"] },
|
||||
});
|
||||
await engine.send(campaign.id);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject deterministic dependencies for tests:
|
||||
|
||||
```ts
|
||||
import { CampaignEngine, InMemoryCampaignStore } from "@suaveplan/campaigns";
|
||||
|
||||
let id = 0;
|
||||
const engine = new CampaignEngine(new InMemoryCampaignStore(), {
|
||||
clock: () => 1_700_000_000_000,
|
||||
idGenerator: () => `cmp-${++id}`,
|
||||
logger: pinoCompatibleLogger,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CampaignEngineOptions` — `{ clock?; idGenerator?; logger? }`.
|
||||
- `CampaignEngine.create(input)`, `.schedule(id, schedule)`, `.send(id)`, `.pause(id)`, `.resume(id)`, `.cancel(id)`, `.complete(id)`, `.fail(id, reason?)`, `.duplicate(id)`, `.updateStats(id, stats)`, `.getById(id)`, `.list(query)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The default id generator uses `cmp_<timestamp>_<rand>`. Inject a deterministic generator in tests. `requireCampaign` is the single helper that produces `CAMPAIGN_NOT_FOUND` — every lifecycle method funnels through it, which keeps the error path uniform. `fail` writes the reason before the status transition so that observers seeing the `failed` status are guaranteed to also see the reason in metadata. `duplicate` deliberately copies `audienceConfig` and other config by reference; downstream callers that need true isolation should clone before mutating.
|
||||
@@ -0,0 +1,63 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable, machine-readable error namespace for `@suaveplan/campaigns`. Every failure path in the package — from invalid options at construction time, through state-machine violations during a send, to missing records and store contract breaches — produces a `CampaignsError` whose `code` is drawn from the `CAMPAIGNS_CODES` registry.
|
||||
|
||||
Centralising the registry here keeps the error namespace decoupled from any one feature module. Downstream consumers can pattern-match on the code constants instead of importing concrete error classes, which keeps deserialised errors (over the wire, across worker boundaries) just as switchable as in-process ones.
|
||||
|
||||
## Features
|
||||
|
||||
- `CAMPAIGNS_CODES` literal map produced by `defineCodes("CAMPAIGNS", [...])`.
|
||||
- `CampaignsCode` discriminating union literal type derived from the registry.
|
||||
- `CampaignsError extends SuaveplanError<CampaignsCode>` with an explicit public constructor so `new CampaignsError(...)` is allowed at any call site.
|
||||
- Eight initial codes covering construction, input validation, state transitions, missing records, store breaches, batch + throttle config validation, and workflow failures.
|
||||
- Codes are namespaced under the `CAMPAIGNS.` prefix so they never clash with codes from sibling packages.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "@suaveplan/campaigns";
|
||||
|
||||
throw new CampaignsError(
|
||||
CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND,
|
||||
"Campaign not found: c-42",
|
||||
{ context: { id: "c-42" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Discriminating in consumer code without an `instanceof` chain:
|
||||
|
||||
```ts
|
||||
import { CAMPAIGNS_CODES, CampaignsError } from "@suaveplan/campaigns";
|
||||
|
||||
try {
|
||||
await engine.send(id);
|
||||
} catch (err) {
|
||||
if (!(err instanceof CampaignsError)) throw err;
|
||||
switch (err.code) {
|
||||
case CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND:
|
||||
return { kind: "not-found" as const };
|
||||
case CAMPAIGNS_CODES.INVALID_TRANSITION:
|
||||
return { kind: "conflict" as const };
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CAMPAIGNS_CODES` — readonly literal map of `KEY -> "CAMPAIGNS.KEY"` strings.
|
||||
- `CampaignsCode` — `typeof CAMPAIGNS_CODES[keyof typeof CAMPAIGNS_CODES]`.
|
||||
- `CampaignsError(code, message, options?)` — public constructor delegating to `SuaveplanError`. `options.cause` chains an underlying error; `options.context` attaches a structured payload.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SuaveplanError`'s base constructor is protected, so without the explicit `public constructor` here, `new CampaignsError(...)` would fail with TS2674. The constructor sets `this.name = "CampaignsError"` so stack traces are self-describing. The `code` field narrows against `CampaignsCode`, which means consumers get exhaustiveness checking on `switch` statements with `noFallthroughCasesInSwitch` enabled.
|
||||
@@ -0,0 +1,59 @@
|
||||
# state-machine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Strictly typed lifecycle state machine for campaigns. Owns the set of nine legal transitions between the seven `CampaignStatus` values and rejects every other combination with a typed `CampaignsError`. The state machine is consumed by `CampaignEngine` for every status-changing operation (`schedule`, `send`, `pause`, `resume`, `cancel`, `complete`, `fail`) and by UI layers that need to enumerate the available next-step actions for the user.
|
||||
|
||||
By centralising the transition rules here we keep the rest of the package free of conditional `if (status === ...)` ladders; new lifecycle events are added by appending to the `TRANSITIONS` table.
|
||||
|
||||
## Features
|
||||
|
||||
- Nine canonical transitions covering draft, scheduled, sending, paused, plus the three terminal states (completed, cancelled, failed).
|
||||
- `transition(currentStatus, event)` returns the next status or throws `CampaignsError(INVALID_TRANSITION)` with the offending `{from, event}` in `context`.
|
||||
- `canTransition(currentStatus, event)` boolean predicate for UI affordances.
|
||||
- `getAvailableEvents(currentStatus)` lists every legal event for the current status — terminal states return `[]`.
|
||||
- Records a `campaigns_transitions_total` counter point per successful transition tagged with `from`, `to`, and `event` labels.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CampaignStateMachine } from "@suaveplan/campaigns";
|
||||
|
||||
const sm = new CampaignStateMachine();
|
||||
const next = sm.transition("draft", "send"); // "sending"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Drive UI buttons from the available-events list:
|
||||
|
||||
```ts
|
||||
import { CampaignStateMachine, type CampaignEvent } from "@suaveplan/campaigns";
|
||||
|
||||
const sm = new CampaignStateMachine();
|
||||
const available: CampaignEvent[] = sm.getAvailableEvents(campaign.status);
|
||||
return available.map((event) => (
|
||||
<Button
|
||||
key={event}
|
||||
disabled={!sm.canTransition(campaign.status, event)}
|
||||
onClick={() => onEvent(event)}
|
||||
>
|
||||
{event}
|
||||
</Button>
|
||||
));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CampaignEvent` — `"schedule" | "send" | "start" | "cancel" | "pause" | "complete" | "fail" | "resume"`.
|
||||
- `CampaignStateMachine.transition(currentStatus, event): CampaignStatus` — throws `CampaignsError` with code `CAMPAIGNS.INVALID_TRANSITION` if the combination is illegal.
|
||||
- `CampaignStateMachine.canTransition(currentStatus, event): boolean`.
|
||||
- `CampaignStateMachine.getAvailableEvents(currentStatus): CampaignEvent[]`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `TRANSITIONS` table is a `readonly Transition[]` — frozen at module load. Lookups are linear (n=9), which is faster in practice than a nested `Record<Status, Record<Event, Status>>` lookup for this size. Adding a new event means appending to the array; removing one is a public API change and requires an OpenSpec proposal. The telemetry record is fired only on success, so the counter never double-counts a rejected transition.
|
||||
56
docs/communication/emails/campaigns/src/store/store.md
Normal file
56
docs/communication/emails/campaigns/src/store/store.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# store
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the `CampaignStore` persistence contract and ships an `InMemoryCampaignStore` reference implementation. Every store responsibility — create, read, update, delete, list with filters, mutate stats, mutate status — is captured here. The engine and workflow modules consume only the contract, never the concrete class, so production deployments can swap in a SurrealDB-, Postgres-, or Durable-Object-backed store without touching the orchestration layer.
|
||||
|
||||
The in-memory implementation is the default for unit tests, prototype workloads, and single-process scenarios. It is byte-for-byte compatible with the SurrealDB plugin's expected surface so workflows written against it port unchanged.
|
||||
|
||||
## Features
|
||||
|
||||
- `CampaignStore` interface with seven methods: `create`, `getById`, `update`, `delete`, `list`, `updateStats`, `updateStatus`.
|
||||
- `InMemoryCampaignStore` backed by `Map<string, Campaign>` with defensive cloning on every read/write so callers cannot tamper with internal records by reference.
|
||||
- Injectable `Clock` on the in-memory implementation so `updatedAt` is reproducible in tests.
|
||||
- `list(query)` filters by `tenantId`, `status`, `type`, `channel`, plus `limit`/`offset` pagination (defaults: 50 / 0).
|
||||
- Missing-record paths throw `CampaignsError(CAMPAIGN_NOT_FOUND)` with the offending id in `context`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CampaignEngine, InMemoryCampaignStore } from "@suaveplan/campaigns";
|
||||
|
||||
const store = new InMemoryCampaignStore();
|
||||
const engine = new CampaignEngine(store);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Implement a custom store backed by a database:
|
||||
|
||||
```ts
|
||||
import type { CampaignStore } from "@suaveplan/campaigns";
|
||||
|
||||
class SurrealCampaignStore implements CampaignStore {
|
||||
async create(c) { /* INSERT */ }
|
||||
async getById(id) { /* SELECT */ }
|
||||
async update(id, input) { /* UPDATE */ }
|
||||
async delete(id) { /* DELETE */ }
|
||||
async list(q) { /* SELECT WHERE */ }
|
||||
async updateStats(id, stats) { /* UPDATE stats */ }
|
||||
async updateStatus(id, status) { /* UPDATE status */ }
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CampaignStore` — interface with seven async methods.
|
||||
- `InMemoryCampaignStoreOptions` — `{ clock?: Clock }`.
|
||||
- `InMemoryCampaignStore` — reference implementation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The defensive clones use `{ ...campaign }` which is a shallow copy. Nested objects (`audienceConfig`, `metadata`, `stats`) are shared by reference; the engine never mutates them in place, but custom store implementations should preserve the same contract. `list()` paginates after filtering — a future change can swap in a database-side `LIMIT/OFFSET`. The default page size of 50 mirrors the upstream API plugin's expectation.
|
||||
@@ -0,0 +1,58 @@
|
||||
# telemetry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
OTel self-telemetry for `@suaveplan/campaigns`. Exposes a small set of counters and histograms that are emitted from every meaningful operation in the package — campaign creation, lifecycle transitions, send dispatches, batch planning, and failures. The instruments resolve against the globally registered `MeterProvider` via `@opentelemetry/api`; when no provider is registered, the OTel API returns no-op meters, so importing this module is always safe in production code that has not opted into telemetry.
|
||||
|
||||
Centralising the meter and record helpers in one module keeps the instrumentation surface explicit and prevents feature modules from drifting into ad-hoc, untagged metrics.
|
||||
|
||||
## Features
|
||||
|
||||
- Five counters: `campaigns_created_total`, `campaigns_transitions_total`, `campaigns_sends_total`, `campaigns_batches_planned_total`, `campaigns_failures_total`.
|
||||
- Two histograms: `campaigns_batch_plan_size` (recipient count per plan) and `campaigns_send_duration_ms` (estimated total send-window duration in ms).
|
||||
- Record helpers (`recordCampaignCreated`, `recordTransition`, `recordSend`, `recordBatchesPlanned`, `recordFailure`, `recordBatchPlanSize`, `recordSendDuration`) so call sites stay readable.
|
||||
- Lazy meter resolution via `metrics.getMeter("@suaveplan/campaigns")` so the module has zero side effects beyond the OTel API itself.
|
||||
- Per-instrument descriptions and units (`ms`, `{contact}`) so dashboards can label axes correctly.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordCampaignCreated, recordSend } from "@suaveplan/campaigns";
|
||||
|
||||
recordCampaignCreated("broadcast", "email");
|
||||
recordSend("email");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pair the histogram with the planner output for percentile dashboards:
|
||||
|
||||
```ts
|
||||
import {
|
||||
BatchPlanner,
|
||||
recordBatchPlanSize,
|
||||
recordSendDuration,
|
||||
} from "@suaveplan/campaigns";
|
||||
|
||||
const planner = new BatchPlanner();
|
||||
const contacts = await audience.resolve(campaign.audienceConfig);
|
||||
const batches = planner.planBatches(contacts, { batchSize: 250 });
|
||||
// planBatches() already records both metrics; helpers are exported so
|
||||
// callers can mirror the recording from custom dispatch loops too.
|
||||
recordBatchPlanSize(contacts.length);
|
||||
recordSendDuration(planner.calculateTotalDuration(contacts.length));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `campaignsCreatedCounter`, `campaignTransitionsCounter`, `campaignSendsCounter`, `batchesPlannedCounter`, `campaignFailuresCounter` — OTel `Counter` instances.
|
||||
- `batchPlanSizeHistogram`, `sendDurationHistogram` — OTel `Histogram` instances.
|
||||
- `recordCampaignCreated(type, channel)`, `recordTransition(from, to, event)`, `recordSend(channel)`, `recordBatchesPlanned(count)`, `recordFailure(channel)`, `recordBatchPlanSize(count)`, `recordSendDuration(ms)` — record helpers.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The meter is acquired once at module load time. Because `getMeter` is idempotent and the OTel API guarantees safe no-op behaviour when no provider is registered, the helpers can be called unconditionally from hot paths. Counters carry low-cardinality labels (`type`, `channel`, `from`, `to`, `event`) — never per-tenant or per-campaign identifiers, which would explode metric series. Add such labels via downstream OTel views if needed.
|
||||
@@ -0,0 +1,70 @@
|
||||
# campaign.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Canonical shared types for `@suaveplan/campaigns`. Every cross-module data shape lives here — `Campaign`, `CampaignStatus`, `CampaignType`, `AudienceConfig`, `CampaignSchedule`, `ThrottleConfig`, `ABTestConfig`, `ABTestVariant`, `CampaignStats`, `CreateCampaignInput`, `UpdateCampaignInput`, `CampaignQuery`, and `BatchConfig` — plus the `DEFAULT_BATCH_CONFIG` constant. Centralising these types here breaks circular dependencies between the engine, state machine, batch planner, workflow, and store modules.
|
||||
|
||||
The types are intentionally framework-agnostic and serialisation-safe. There are no class instances, no `Map`s, no `Set`s — only plain object shapes whose JSON representation round-trips cleanly through any persistence or transport layer.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven `CampaignStatus` values modelling the full lifecycle (`draft`, `scheduled`, `sending`, `paused`, `completed`, `cancelled`, `failed`).
|
||||
- Four `CampaignType` values (`broadcast`, `triggered`, `ab_test`, `drip`).
|
||||
- `AudienceConfig` with inclusion (`groupIds`), exclusion (`excludeGroupIds`, `excludeContactIds`), segment-driven targeting (`segmentIds`), and explicit suppression-list references (`suppressionListIds`).
|
||||
- `CampaignSchedule` covering immediate, scheduled, and recurring sends.
|
||||
- `ThrottleConfig` with per-second / per-minute / per-hour limits and optional ramp-up duration.
|
||||
- `ABTestConfig` + `ABTestVariant` with even or weighted splits, optional test duration, and the standard `open_rate` / `click_rate` / `conversion` winner criteria.
|
||||
- `Campaign` generic over an arbitrary `TMeta` metadata shape, with reset-safe `stats: CampaignStats`.
|
||||
- Input shapes (`CreateCampaignInput`, `UpdateCampaignInput`, `CampaignQuery`) that mirror the engine's public surface.
|
||||
- `BatchConfig` + `DEFAULT_BATCH_CONFIG` (`{ batchSize: 100, delayBetweenBatches: 0 }`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { Campaign, CampaignStatus } from "@suaveplan/campaigns";
|
||||
|
||||
function isTerminal(status: CampaignStatus): boolean {
|
||||
return (
|
||||
status === "completed" ||
|
||||
status === "cancelled" ||
|
||||
status === "failed"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Parameterise the metadata shape per tenant:
|
||||
|
||||
```ts
|
||||
import type { Campaign, CreateCampaignInput } from "@suaveplan/campaigns";
|
||||
|
||||
interface AcmeMeta {
|
||||
readonly source: "api" | "ui" | "webhook";
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
const input: CreateCampaignInput<AcmeMeta> = {
|
||||
tenantId: "acme",
|
||||
name: "Quarterly newsletter",
|
||||
type: "broadcast",
|
||||
channel: "email",
|
||||
audienceConfig: { groupIds: ["all"] },
|
||||
metadata: { source: "api", correlationId: "corr-1" },
|
||||
};
|
||||
|
||||
declare const campaign: Campaign<AcmeMeta>;
|
||||
campaign.metadata.correlationId; // strongly typed
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CampaignStatus`, `CampaignType` — string-literal unions.
|
||||
- `AudienceConfig`, `CampaignSchedule`, `ThrottleConfig`, `ABTestConfig`, `ABTestVariant`, `CampaignStats` — shape interfaces.
|
||||
- `Campaign<TMeta>` — main aggregate.
|
||||
- `CreateCampaignInput<TMeta>`, `UpdateCampaignInput<TMeta>`, `CampaignQuery` — engine input shapes.
|
||||
- `BatchConfig`, `DEFAULT_BATCH_CONFIG` — batch sizing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`AudienceConfig` uses `readonly` arrays so call sites cannot mutate config in place. `Campaign.stats` is mutable on purpose — the engine reads-modifies-writes it during `updateStats`. Adding a new `CampaignStatus` or `CampaignType` is a public API change and requires an OpenSpec proposal because the state machine, the API plugin, and persistence schemas all switch on these literal unions.
|
||||
60
docs/communication/emails/campaigns/src/workflow/workflow.md
Normal file
60
docs/communication/emails/campaigns/src/workflow/workflow.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# workflow
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Composable multi-step send pipeline for campaigns. `CampaignWorkflow.buildSendPipeline` returns a canonical six-step pipeline — `resolve-audience`, `apply-suppression`, `plan-batches`, `send-batches`, `update-stats`, `complete` — that downstream integrations can wrap, instrument, or replace step-by-step. `CampaignWorkflow.run` executes the supplied step list against an initial `WorkflowContext`, threading the result of each step into the next, and wraps any step-level error in a typed `CampaignsError` so callers can pattern-match on the canonical registry.
|
||||
|
||||
The workflow is intentionally minimal — it owns ordering and error handling, not business logic. Each step is a plain async function that returns the next `WorkflowContext`, which keeps swapping in real audience resolvers, suppression services, and dispatchers a one-line override.
|
||||
|
||||
## Features
|
||||
|
||||
- Canonical six-step send pipeline returned by `buildSendPipeline(campaign)`.
|
||||
- `run(steps, initial)` executes every step in order, threading context through.
|
||||
- Errors raised by a step are wrapped in `CampaignsError(WORKFLOW_STEP_FAILED)` with the failing step id in `context.stepId` and the original error preserved on `cause`.
|
||||
- Injectable `LoggerLike` for debug-level tracing of step start/end events.
|
||||
- `WorkflowStep` carries `id`, `name`, `description`, and an async `execute` — descriptions surface in UI-driven progress panels.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CampaignWorkflow } from "@suaveplan/campaigns";
|
||||
|
||||
const workflow = new CampaignWorkflow();
|
||||
const steps = workflow.buildSendPipeline(campaign);
|
||||
const result = await workflow.run(steps, { campaign });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Override individual steps with real integrations:
|
||||
|
||||
```ts
|
||||
import { CampaignWorkflow, type WorkflowStep } from "@suaveplan/campaigns";
|
||||
|
||||
const workflow = new CampaignWorkflow({ logger });
|
||||
const defaults = workflow.buildSendPipeline(campaign);
|
||||
const overrides: WorkflowStep[] = defaults.map((step) =>
|
||||
step.id === "resolve-audience"
|
||||
? { ...step, execute: async (ctx) => ({ ...ctx, contactIds: await audience.resolve(campaign) }) }
|
||||
: step.id === "send-batches"
|
||||
? { ...step, execute: async (ctx) => dispatch(ctx) }
|
||||
: step
|
||||
);
|
||||
const result = await workflow.run(overrides, { campaign });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `WorkflowContext` — `{ campaign; contactIds?; batches?; results? }`.
|
||||
- `WorkflowStep` — `{ id; name; description; execute(context) }`.
|
||||
- `CampaignWorkflowOptions` — `{ logger? }`.
|
||||
- `CampaignWorkflow.buildSendPipeline(campaign): WorkflowStep[]`.
|
||||
- `CampaignWorkflow.run(steps, initial): Promise<WorkflowContext>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`run` executes sequentially — there is no parallel-step support, because the default pipeline is strictly ordered (you can't send batches before you've planned them). For fan-out scenarios, wrap `send-batches` to dispatch its work to a worker pool. Error wrapping uses `{ cause }`, so `err.cause` preserves the original throw and is consumable by `Error.cause` walkers and structured-logger redactors.
|
||||
Reference in New Issue
Block a user