first commit

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

View File

@@ -0,0 +1,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.

View File

@@ -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.

View 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.

View File

@@ -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.

View File

@@ -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.

View 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.

View File

@@ -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.

View File

@@ -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.

View 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.