Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,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,109 @@
{
"name": "@suaveplan/campaigns",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./engine": {
"import": {
"types": "./dist/engine/index.d.ts",
"default": "./dist/engine/index.js"
}
},
"./state-machine": {
"import": {
"types": "./dist/state-machine/index.d.ts",
"default": "./dist/state-machine/index.js"
}
},
"./batch-planner": {
"import": {
"types": "./dist/batch-planner/index.d.ts",
"default": "./dist/batch-planner/index.js"
}
},
"./workflow": {
"import": {
"types": "./dist/workflow/index.d.ts",
"default": "./dist/workflow/index.js"
}
},
"./store": {
"import": {
"types": "./dist/store/index.d.ts",
"default": "./dist/store/index.js"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./telemetry": {
"import": {
"types": "./dist/telemetry/index.d.ts",
"default": "./dist/telemetry/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

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,233 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
import { BatchPlanner } from "./batch-planner.js";
function makeContactIds(count: number): string[] {
return Array.from({ length: count }, (_, i) => `contact-${i}`);
}
const FIXED_NOW = 1_700_000_000_000;
function fixedClock(): number {
return FIXED_NOW;
}
describe("BatchPlanner", () => {
describe("construction", () => {
it("accepts default options", { timeout: 5000 }, () => {
const planner = new BatchPlanner();
expect(planner).toBeInstanceOf(BatchPlanner);
});
it("accepts injected clock", { timeout: 5000 }, () => {
const planner = new BatchPlanner({ clock: fixedClock });
const batches = planner.planBatches(makeContactIds(200), {
batchSize: 100,
delayBetweenBatches: 1000,
});
expect(batches[1].scheduledAt?.getTime()).toBe(FIXED_NOW + 1000);
});
it("rejects unknown option keys", { timeout: 5000 }, () => {
try {
new BatchPlanner({ bogus: 1 } as unknown as never);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(CampaignsError);
if (err instanceof CampaignsError) {
expect(err.code).toBe(CAMPAIGNS_CODES.INVALID_OPTIONS);
}
}
});
});
describe("planBatches", () => {
const planner = new BatchPlanner({ clock: fixedClock });
it("returns empty for no contacts", { timeout: 5000 }, () => {
expect(planner.planBatches([])).toEqual([]);
});
it("creates single batch for small lists", {
timeout: 5000,
}, () => {
const batches = planner.planBatches(makeContactIds(50), {
batchSize: 100,
});
expect(batches).toHaveLength(1);
expect(batches[0].batchIndex).toBe(0);
expect(batches[0].contactIds).toHaveLength(50);
expect(batches[0].scheduledAt).toBeUndefined();
});
it("splits into multiple batches", { timeout: 5000 }, () => {
const batches = planner.planBatches(makeContactIds(250), {
batchSize: 100,
});
expect(batches).toHaveLength(3);
expect(batches[0].contactIds).toHaveLength(100);
expect(batches[2].contactIds).toHaveLength(50);
});
it("uses default batch size of 100", { timeout: 5000 }, () => {
const batches = planner.planBatches(makeContactIds(150));
expect(batches).toHaveLength(2);
});
it("schedules with explicit delay", { timeout: 5000 }, () => {
const batches = planner.planBatches(makeContactIds(300), {
batchSize: 100,
delayBetweenBatches: 1000,
});
expect(batches[0].scheduledAt).toBeUndefined();
expect(batches[1].scheduledAt?.getTime()).toBe(FIXED_NOW + 1000);
expect(batches[2].scheduledAt?.getTime()).toBe(FIXED_NOW + 2000);
});
it("respects messagesPerSecond throttle", { timeout: 5000 }, () => {
const batches = planner.planBatches(
makeContactIds(200),
{ batchSize: 100 },
{ messagesPerSecond: 50 }
);
expect(batches).toHaveLength(2);
expect(batches[1].scheduledAt?.getTime()).toBe(FIXED_NOW + 2000);
});
it("respects messagesPerMinute throttle", { timeout: 5000 }, () => {
const batches = planner.planBatches(
makeContactIds(200),
{ batchSize: 100 },
{ messagesPerMinute: 100 }
);
expect(batches[1].scheduledAt?.getTime()).toBe(FIXED_NOW + 60_000);
});
it("respects messagesPerHour throttle", { timeout: 5000 }, () => {
const batches = planner.planBatches(
makeContactIds(200),
{ batchSize: 100 },
{ messagesPerHour: 1000 }
);
expect(batches[1].scheduledAt).toBeDefined();
});
it("falls back to baseDelay when throttle has only ramp fields", {
timeout: 5000,
}, () => {
const batches = planner.planBatches(
makeContactIds(200),
{ batchSize: 100, delayBetweenBatches: 500 },
{ rampUp: true, rampUpDuration: 1000 }
);
expect(batches[1].scheduledAt?.getTime()).toBe(FIXED_NOW + 500);
});
it("rejects invalid batch config", { timeout: 5000 }, () => {
try {
planner.planBatches(makeContactIds(10), { batchSize: -1 });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(CampaignsError);
if (err instanceof CampaignsError) {
expect(err.code).toBe(CAMPAIGNS_CODES.INVALID_BATCH_CONFIG);
}
}
});
it("rejects invalid throttle", { timeout: 5000 }, () => {
try {
planner.planBatches(
makeContactIds(10),
{ batchSize: 5 },
{ messagesPerSecond: -1 }
);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(CampaignsError);
if (err instanceof CampaignsError) {
expect(err.code).toBe(
CAMPAIGNS_CODES.INVALID_THROTTLE_CONFIG
);
}
}
});
});
describe("calculateTotalDuration", () => {
const planner = new BatchPlanner({ clock: fixedClock });
it("returns 0 for no contacts", { timeout: 5000 }, () => {
expect(planner.calculateTotalDuration(0)).toBe(0);
});
it("returns 0 for negative contact count", { timeout: 5000 }, () => {
expect(planner.calculateTotalDuration(-5)).toBe(0);
});
it("returns 0 for single batch", { timeout: 5000 }, () => {
expect(planner.calculateTotalDuration(50, { batchSize: 100 })).toBe(
0
);
});
it("computes from batches + delay", { timeout: 5000 }, () => {
expect(
planner.calculateTotalDuration(300, {
batchSize: 100,
delayBetweenBatches: 1000,
})
).toBe(2000);
});
it("computes with throttle", { timeout: 5000 }, () => {
expect(
planner.calculateTotalDuration(
200,
{ batchSize: 100 },
{ messagesPerSecond: 50 }
)
).toBe(2000);
});
it("uses max of explicit + throttle-derived delay", {
timeout: 5000,
}, () => {
expect(
planner.calculateTotalDuration(
200,
{ batchSize: 100, delayBetweenBatches: 5000 },
{ messagesPerSecond: 50 }
)
).toBe(5000);
});
it("rejects invalid batch config", { timeout: 5000 }, () => {
expect(() =>
planner.calculateTotalDuration(10, { batchSize: 0 })
).toThrow(CampaignsError);
});
it("rejects invalid throttle config", { timeout: 5000 }, () => {
expect(() =>
planner.calculateTotalDuration(10, {}, { messagesPerMinute: 0 })
).toThrow(CampaignsError);
});
});
describe("default clock", () => {
it("uses Date.now when no clock is injected", {
timeout: 5000,
}, () => {
const planner = new BatchPlanner();
const before = Date.now();
const batches = planner.planBatches(makeContactIds(200), {
batchSize: 100,
delayBetweenBatches: 1000,
});
const after = Date.now();
const t = batches[1].scheduledAt!.getTime();
expect(t).toBeGreaterThanOrEqual(before + 1000);
expect(t).toBeLessThanOrEqual(after + 1000);
});
});
});

View File

@@ -0,0 +1,201 @@
import type { Clock } from "@suaveplan/types";
import z from "zod";
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
import {
recordBatchesPlanned,
recordBatchPlanSize,
recordSendDuration,
} from "../telemetry/telemetry.js";
import {
type BatchConfig,
DEFAULT_BATCH_CONFIG,
type ThrottleConfig,
} from "../types/campaign.types.js";
/** A single batch in a campaign send plan. */
export interface Batch {
readonly batchIndex: number;
readonly contactIds: readonly string[];
readonly scheduledAt?: Date;
}
/** Construction options for {@link BatchPlanner}. */
export interface BatchPlannerOptions {
/** Injectable wall-clock source. Defaults to `Date.now`. */
readonly clock?: Clock;
}
const BatchPlannerOptionsSchema = z
.object({
clock: z.custom<Clock>().optional(),
})
.strict();
const BatchConfigSchema = z
.object({
batchSize: z.number().int().positive().optional(),
delayBetweenBatches: z.number().nonnegative().optional(),
})
.strict();
const ThrottleConfigSchema = z
.object({
messagesPerSecond: z.number().positive().optional(),
messagesPerMinute: z.number().positive().optional(),
messagesPerHour: z.number().positive().optional(),
rampUp: z.boolean().optional(),
rampUpDuration: z.number().nonnegative().optional(),
})
.strict();
/**
* Splits a contact list into sized batches with throttle-aware send
* timestamps.
*
* The planner is purely deterministic given a fixed clock. The clock is
* injectable so callers (and tests) can pin the `t=0` of a plan to an
* arbitrary point.
*/
export class BatchPlanner {
private readonly clock: Clock;
public constructor(options: BatchPlannerOptions = {}) {
const parsed = BatchPlannerOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new CampaignsError(
CAMPAIGNS_CODES.INVALID_OPTIONS,
"Invalid BatchPlanner options",
{ context: { issues: parsed.error.issues } }
);
}
this.clock = parsed.data.clock ?? (() => Date.now());
}
/** Split `contactIds` into batches respecting `config` + `throttle`. */
public planBatches(
contactIds: readonly string[],
config: Partial<BatchConfig> = {},
throttle?: ThrottleConfig
): Batch[] {
this.validateConfig(config);
this.validateThrottle(throttle);
if (contactIds.length === 0) {
recordBatchPlanSize(0);
recordBatchesPlanned(0);
return [];
}
const { batchSize, delayBetweenBatches } = {
...DEFAULT_BATCH_CONFIG,
...config,
};
const effectiveDelay = this.resolveDelay(
delayBetweenBatches,
batchSize,
throttle
);
const batches: Batch[] = [];
const now = this.clock();
for (let i = 0; i < contactIds.length; i += batchSize) {
const batchIndex = batches.length;
const slice = contactIds.slice(i, i + batchSize);
const batch: Batch =
effectiveDelay > 0 && batchIndex > 0
? {
batchIndex,
contactIds: slice,
scheduledAt: new Date(
now + batchIndex * effectiveDelay
),
}
: { batchIndex, contactIds: slice };
batches.push(batch);
}
recordBatchPlanSize(contactIds.length);
recordBatchesPlanned(batches.length);
if (batches.length > 1) {
recordSendDuration((batches.length - 1) * effectiveDelay);
}
return batches;
}
/** Estimate total send-window duration in milliseconds. */
public calculateTotalDuration(
totalContacts: number,
config: Partial<BatchConfig> = {},
throttle?: ThrottleConfig
): number {
this.validateConfig(config);
this.validateThrottle(throttle);
if (totalContacts <= 0) {
return 0;
}
const { batchSize, delayBetweenBatches } = {
...DEFAULT_BATCH_CONFIG,
...config,
};
const effectiveDelay = this.resolveDelay(
delayBetweenBatches,
batchSize,
throttle
);
const totalBatches = Math.ceil(totalContacts / batchSize);
const duration =
totalBatches > 1 ? (totalBatches - 1) * effectiveDelay : 0;
recordSendDuration(duration);
return duration;
}
private validateConfig(config: Partial<BatchConfig>): void {
const parsed = BatchConfigSchema.safeParse(config);
if (!parsed.success) {
throw new CampaignsError(
CAMPAIGNS_CODES.INVALID_BATCH_CONFIG,
"Invalid batch config",
{ context: { issues: parsed.error.issues } }
);
}
}
private validateThrottle(throttle: ThrottleConfig | undefined): void {
if (!throttle) return;
const parsed = ThrottleConfigSchema.safeParse(throttle);
if (!parsed.success) {
throw new CampaignsError(
CAMPAIGNS_CODES.INVALID_THROTTLE_CONFIG,
"Invalid throttle config",
{ context: { issues: parsed.error.issues } }
);
}
}
private resolveDelay(
baseDelay: number,
batchSize: number,
throttle?: ThrottleConfig
): number {
if (!throttle) {
return baseDelay;
}
if (throttle.messagesPerSecond) {
const secondsPerBatch = batchSize / throttle.messagesPerSecond;
return Math.max(baseDelay, secondsPerBatch * 1000);
}
if (throttle.messagesPerMinute) {
const minutesPerBatch = batchSize / throttle.messagesPerMinute;
return Math.max(baseDelay, minutesPerBatch * 60_000);
}
if (throttle.messagesPerHour) {
const hoursPerBatch = batchSize / throttle.messagesPerHour;
return Math.max(baseDelay, hoursPerBatch * 3_600_000);
}
return baseDelay;
}
}

View File

@@ -0,0 +1,2 @@
export type { Batch, BatchPlannerOptions } from "./batch-planner.js";
export { BatchPlanner } from "./batch-planner.js";

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,282 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
import { InMemoryCampaignStore } from "../store/store.js";
import type { CreateCampaignInput } from "../types/campaign.types.js";
import { CampaignEngine } from "./engine.js";
function makeInput(
overrides: Partial<CreateCampaignInput> = {}
): CreateCampaignInput {
return {
tenantId: "tenant-1",
name: "Test Campaign",
type: "broadcast",
channel: "email",
audienceConfig: { groupIds: ["group-1"] },
...overrides,
};
}
describe("CampaignEngine", () => {
let engine: CampaignEngine;
let counter = 0;
let now = 1_700_000_000_000;
const logs: { level: string; message: string }[] = [];
const logger = {
debug: (m: string) => logs.push({ level: "debug", message: m }),
info: (m: string) => logs.push({ level: "info", message: m }),
warn: (m: string) => logs.push({ level: "warn", message: m }),
error: (m: string) => logs.push({ level: "error", message: m }),
};
beforeEach(() => {
counter = 0;
now = 1_700_000_000_000;
logs.length = 0;
engine = new CampaignEngine(new InMemoryCampaignStore(), {
clock: () => now,
idGenerator: () => `cmp-${++counter}`,
logger,
});
});
describe("constructor", () => {
it("rejects invalid options", { timeout: 5000 }, () => {
try {
new CampaignEngine(new InMemoryCampaignStore(), {
bogus: 1,
} as unknown as never);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(CampaignsError);
if (err instanceof CampaignsError) {
expect(err.code).toBe(CAMPAIGNS_CODES.INVALID_OPTIONS);
}
}
});
it("works with default options (no clock/id/logger)", {
timeout: 5000,
}, async () => {
const e = new CampaignEngine(new InMemoryCampaignStore());
const c = await e.create(makeInput());
expect(c.id).toMatch(/^cmp_/);
expect(c.status).toBe("draft");
});
});
describe("create", () => {
it("creates in draft status with empty stats", {
timeout: 5000,
}, async () => {
const c = await engine.create(makeInput());
expect(c.id).toBe("cmp-1");
expect(c.status).toBe("draft");
expect(c.stats.sent).toBe(0);
expect(c.metadata).toEqual({});
expect(
logs.find((l) => l.message === "campaign.created")
).toBeDefined();
});
it("preserves metadata", { timeout: 5000 }, async () => {
const c = await engine.create(
makeInput({ metadata: { source: "api" } })
);
expect(c.metadata).toEqual({ source: "api" });
});
it("preserves all optional fields when supplied", {
timeout: 5000,
}, async () => {
const c = await engine.create(
makeInput({
templateId: "tmpl-1",
templateData: { name: "Paul" },
schedule: { type: "immediate" },
abTestConfig: {
variants: [
{
id: "a",
name: "A",
templateId: "t-a",
weight: 50,
},
],
splitType: "even",
},
throttle: { messagesPerSecond: 10 },
})
);
expect(c.templateId).toBe("tmpl-1");
expect(c.templateData).toEqual({ name: "Paul" });
expect(c.schedule).toEqual({ type: "immediate" });
expect(c.abTestConfig?.splitType).toBe("even");
expect(c.throttle?.messagesPerSecond).toBe(10);
});
it("rejects invalid input (empty tenantId)", {
timeout: 5000,
}, async () => {
try {
await engine.create({ ...makeInput(), tenantId: "" });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(CampaignsError);
if (err instanceof CampaignsError) {
expect(err.code).toBe(CAMPAIGNS_CODES.INVALID_INPUT);
}
}
});
});
describe("schedule", () => {
it("schedules a draft campaign", { timeout: 5000 }, async () => {
const c = await engine.create(makeInput());
const s = await engine.schedule(c.id, {
type: "scheduled",
sendAt: new Date("2026-04-01"),
});
expect(s.status).toBe("scheduled");
expect(s.schedule?.type).toBe("scheduled");
});
it("throws when scheduling a sending campaign", {
timeout: 5000,
}, async () => {
const c = await engine.create(makeInput());
await engine.send(c.id);
await expect(
engine.schedule(c.id, { type: "immediate" })
).rejects.toThrow(/Invalid transition/);
});
});
describe("send", () => {
it("draft -> sending", { timeout: 5000 }, async () => {
const c = await engine.create(makeInput());
const sent = await engine.send(c.id);
expect(sent.status).toBe("sending");
});
it("throws for non-existent campaign", {
timeout: 5000,
}, async () => {
try {
await engine.send("nonexistent");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(CampaignsError);
if (err instanceof CampaignsError) {
expect(err.code).toBe(CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND);
}
}
});
});
describe("pause/resume", () => {
it("pause + resume", { timeout: 5000 }, async () => {
const c = await engine.create(makeInput());
await engine.send(c.id);
const paused = await engine.pause(c.id);
expect(paused.status).toBe("paused");
const resumed = await engine.resume(c.id);
expect(resumed.status).toBe("sending");
});
});
describe("cancel", () => {
it("cancel a scheduled campaign", { timeout: 5000 }, async () => {
const c = await engine.create(makeInput());
await engine.schedule(c.id, { type: "immediate" });
expect((await engine.cancel(c.id)).status).toBe("cancelled");
});
it("cancel a paused campaign", { timeout: 5000 }, async () => {
const c = await engine.create(makeInput());
await engine.send(c.id);
await engine.pause(c.id);
expect((await engine.cancel(c.id)).status).toBe("cancelled");
});
});
describe("complete", () => {
it("sending -> completed", { timeout: 5000 }, async () => {
const c = await engine.create(makeInput());
await engine.send(c.id);
expect((await engine.complete(c.id)).status).toBe("completed");
});
});
describe("fail", () => {
it("sending -> failed with reason", { timeout: 5000 }, async () => {
const c = await engine.create(makeInput());
await engine.send(c.id);
await engine.fail(c.id, "Provider error");
const fetched = await engine.getById(c.id);
expect(fetched?.status).toBe("failed");
expect(fetched?.metadata).toEqual({
failureReason: "Provider error",
});
expect(
logs.find((l) => l.message === "campaign.failed")
).toBeDefined();
});
it("fail without reason", { timeout: 5000 }, async () => {
const c = await engine.create(makeInput());
await engine.send(c.id);
const failed = await engine.fail(c.id);
expect(failed.status).toBe("failed");
expect(failed.metadata).toEqual({});
});
});
describe("duplicate", () => {
it("draft copy with reset stats and (copy) suffix", {
timeout: 5000,
}, async () => {
const c = await engine.create(makeInput({ name: "Original" }));
await engine.send(c.id);
const copy = await engine.duplicate(c.id);
expect(copy.id).not.toBe(c.id);
expect(copy.name).toBe("Original (copy)");
expect(copy.status).toBe("draft");
expect(copy.stats.sent).toBe(0);
expect(copy.sentAt).toBeUndefined();
});
});
describe("updateStats", () => {
it("merges stats", { timeout: 5000 }, async () => {
const c = await engine.create(makeInput());
const updated = await engine.updateStats(c.id, {
sent: 50,
delivered: 48,
});
expect(updated.stats.sent).toBe(50);
expect(updated.stats.delivered).toBe(48);
expect(updated.stats.opened).toBe(0);
});
it("throws for missing", { timeout: 5000 }, async () => {
await expect(
engine.updateStats("nope", { sent: 1 })
).rejects.toBeInstanceOf(CampaignsError);
});
});
describe("list + getById", () => {
it("filters by channel", { timeout: 5000 }, async () => {
await engine.create(makeInput({ name: "A", channel: "email" }));
await engine.create(makeInput({ name: "B", channel: "sms" }));
const emails = await engine.list({ channel: "email" });
expect(emails).toHaveLength(1);
expect(emails[0].name).toBe("A");
});
it("getById null for missing", { timeout: 5000 }, async () => {
expect(await engine.getById("nope")).toBeNull();
});
});
});

View File

@@ -0,0 +1,275 @@
import type { Clock, IdGenerator, LoggerLike } from "@suaveplan/types";
import z from "zod";
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
import { CampaignStateMachine } from "../state-machine/state-machine.js";
import type { CampaignStore } from "../store/store.js";
import {
recordCampaignCreated,
recordFailure,
recordSend,
} from "../telemetry/telemetry.js";
import type {
Campaign,
CampaignQuery,
CampaignSchedule,
CampaignStats,
CreateCampaignInput,
} from "../types/campaign.types.js";
/** Construction options for {@link CampaignEngine}. */
export interface CampaignEngineOptions {
/** Injectable wall-clock source. Defaults to `Date.now`. */
readonly clock?: Clock;
/** Injectable id generator. Defaults to a `cmp_<timestamp>_<rand>` factory. */
readonly idGenerator?: IdGenerator;
/** Optional logger for state transitions + failure traces. */
readonly logger?: LoggerLike;
}
const EngineOptionsSchema = z
.object({
clock: z.custom<Clock>().optional(),
idGenerator: z.custom<IdGenerator>().optional(),
logger: z.custom<LoggerLike>().optional(),
})
.strict();
const CreateInputSchema = z
.object({
tenantId: z.string().min(1),
name: z.string().min(1),
type: z.enum(["broadcast", "triggered", "ab_test", "drip"]),
channel: z.string().min(1),
audienceConfig: z.object({
groupIds: z.array(z.string()).readonly(),
excludeGroupIds: z.array(z.string()).readonly().optional(),
excludeContactIds: z.array(z.string()).readonly().optional(),
segmentIds: z.array(z.string()).readonly().optional(),
suppressionListIds: z.array(z.string()).readonly().optional(),
}),
templateId: z.string().optional(),
templateData: z.record(z.string(), z.unknown()).optional(),
schedule: z
.object({
type: z.enum(["immediate", "scheduled", "recurring"]),
sendAt: z.date().optional(),
cronExpression: z.string().optional(),
timezone: z.string().optional(),
})
.optional(),
abTestConfig: z.unknown().optional(),
throttle: z.unknown().optional(),
metadata: z.unknown().optional(),
})
.passthrough();
function emptyStats(): CampaignStats {
return {
totalRecipients: 0,
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
unsubscribed: 0,
complained: 0,
failed: 0,
};
}
function defaultIdGenerator(): string {
return `cmp_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
}
/**
* Top-level CRUD + lifecycle orchestrator for campaigns. Delegates state
* validation to {@link CampaignStateMachine} and persistence to the
* injected {@link CampaignStore}.
*/
export class CampaignEngine {
private readonly store: CampaignStore;
private readonly stateMachine: CampaignStateMachine;
private readonly clock: Clock;
private readonly idGenerator: IdGenerator;
private readonly logger?: LoggerLike;
public constructor(
store: CampaignStore,
options: CampaignEngineOptions = {}
) {
const parsed = EngineOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new CampaignsError(
CAMPAIGNS_CODES.INVALID_OPTIONS,
"Invalid CampaignEngine options",
{ context: { issues: parsed.error.issues } }
);
}
this.store = store;
this.stateMachine = new CampaignStateMachine();
this.clock = parsed.data.clock ?? (() => Date.now());
this.idGenerator = parsed.data.idGenerator ?? defaultIdGenerator;
if (parsed.data.logger !== undefined) {
this.logger = parsed.data.logger;
}
}
public async create(input: CreateCampaignInput): Promise<Campaign> {
const parsed = CreateInputSchema.safeParse(input);
if (!parsed.success) {
throw new CampaignsError(
CAMPAIGNS_CODES.INVALID_INPUT,
"Invalid CreateCampaignInput",
{ context: { issues: parsed.error.issues } }
);
}
const now = new Date(this.clock());
const campaign: Campaign = {
id: this.idGenerator(),
tenantId: input.tenantId,
name: input.name,
status: "draft",
type: input.type,
channel: input.channel,
audienceConfig: input.audienceConfig,
metadata: input.metadata ?? {},
stats: emptyStats(),
createdAt: now,
updatedAt: now,
};
if (input.templateId !== undefined)
campaign.templateId = input.templateId;
if (input.templateData !== undefined)
campaign.templateData = input.templateData;
if (input.schedule !== undefined) campaign.schedule = input.schedule;
if (input.abTestConfig !== undefined)
campaign.abTestConfig = input.abTestConfig;
if (input.throttle !== undefined) campaign.throttle = input.throttle;
const created = await this.store.create(campaign);
recordCampaignCreated(created.type, created.channel);
this.logger?.info("campaign.created", {
id: created.id,
type: created.type,
channel: created.channel,
});
return created;
}
public async schedule(
campaignId: string,
schedule: CampaignSchedule
): Promise<Campaign> {
const campaign = await this.requireCampaign(campaignId);
this.stateMachine.transition(campaign.status, "schedule");
await this.store.update(campaignId, { schedule });
return this.store.updateStatus(campaignId, "scheduled");
}
public async send(campaignId: string): Promise<Campaign> {
const campaign = await this.requireCampaign(campaignId);
const newStatus = this.stateMachine.transition(campaign.status, "send");
const updated = await this.store.updateStatus(campaignId, newStatus);
recordSend(updated.channel);
return updated;
}
public async pause(campaignId: string): Promise<Campaign> {
const campaign = await this.requireCampaign(campaignId);
const newStatus = this.stateMachine.transition(
campaign.status,
"pause"
);
return this.store.updateStatus(campaignId, newStatus);
}
public async resume(campaignId: string): Promise<Campaign> {
const campaign = await this.requireCampaign(campaignId);
const newStatus = this.stateMachine.transition(
campaign.status,
"resume"
);
return this.store.updateStatus(campaignId, newStatus);
}
public async cancel(campaignId: string): Promise<Campaign> {
const campaign = await this.requireCampaign(campaignId);
const newStatus = this.stateMachine.transition(
campaign.status,
"cancel"
);
return this.store.updateStatus(campaignId, newStatus);
}
public async complete(campaignId: string): Promise<Campaign> {
const campaign = await this.requireCampaign(campaignId);
const newStatus = this.stateMachine.transition(
campaign.status,
"complete"
);
return this.store.updateStatus(campaignId, newStatus);
}
public async fail(campaignId: string, reason?: string): Promise<Campaign> {
const campaign = await this.requireCampaign(campaignId);
const newStatus = this.stateMachine.transition(campaign.status, "fail");
if (reason !== undefined) {
await this.store.update(campaignId, {
metadata: { ...campaign.metadata, failureReason: reason },
});
}
const updated = await this.store.updateStatus(campaignId, newStatus);
recordFailure(updated.channel);
this.logger?.warn("campaign.failed", {
id: campaignId,
reason,
});
return updated;
}
public async duplicate(campaignId: string): Promise<Campaign> {
const campaign = await this.requireCampaign(campaignId);
const now = new Date(this.clock());
const copy: Campaign = {
...campaign,
id: this.idGenerator(),
name: `${campaign.name} (copy)`,
status: "draft",
stats: emptyStats(),
createdAt: now,
updatedAt: now,
};
delete copy.sentAt;
delete copy.completedAt;
const created = await this.store.create(copy);
recordCampaignCreated(created.type, created.channel);
return created;
}
public async updateStats(
campaignId: string,
stats: Partial<CampaignStats>
): Promise<Campaign> {
await this.requireCampaign(campaignId);
return this.store.updateStats(campaignId, stats);
}
public async getById(campaignId: string): Promise<Campaign | null> {
return this.store.getById(campaignId);
}
public async list(query: CampaignQuery): Promise<Campaign[]> {
return this.store.list(query);
}
private async requireCampaign(id: string): Promise<Campaign> {
const campaign = await this.store.getById(id);
if (!campaign) {
throw new CampaignsError(
CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND,
`Campaign not found: ${id}`,
{ context: { id } }
);
}
return campaign;
}
}

View File

@@ -0,0 +1,2 @@
export type { CampaignEngineOptions } from "./engine.js";
export { CampaignEngine } from "./engine.js";

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,67 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CAMPAIGNS_CODES, CampaignsError } from "./error-codes.js";
describe("CAMPAIGNS_CODES", () => {
it("exposes every documented code", { timeout: 5000 }, () => {
expect(CAMPAIGNS_CODES.INVALID_OPTIONS).toBe(
"CAMPAIGNS.INVALID_OPTIONS"
);
expect(CAMPAIGNS_CODES.INVALID_INPUT).toBe("CAMPAIGNS.INVALID_INPUT");
expect(CAMPAIGNS_CODES.INVALID_TRANSITION).toBe(
"CAMPAIGNS.INVALID_TRANSITION"
);
expect(CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND).toBe(
"CAMPAIGNS.CAMPAIGN_NOT_FOUND"
);
expect(CAMPAIGNS_CODES.STORE_FAILURE).toBe("CAMPAIGNS.STORE_FAILURE");
expect(CAMPAIGNS_CODES.INVALID_BATCH_CONFIG).toBe(
"CAMPAIGNS.INVALID_BATCH_CONFIG"
);
expect(CAMPAIGNS_CODES.INVALID_THROTTLE_CONFIG).toBe(
"CAMPAIGNS.INVALID_THROTTLE_CONFIG"
);
expect(CAMPAIGNS_CODES.WORKFLOW_STEP_FAILED).toBe(
"CAMPAIGNS.WORKFLOW_STEP_FAILED"
);
});
});
describe("CampaignsError", () => {
it("constructs with code + message", { timeout: 5000 }, () => {
const err = new CampaignsError(
CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND,
"missing"
);
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(CampaignsError);
expect(err.code).toBe(CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND);
expect(err.message).toBe("missing");
expect(err.name).toBe("CampaignsError");
});
it("threads cause + context", { timeout: 5000 }, () => {
const cause = new Error("upstream");
const err = new CampaignsError(CAMPAIGNS_CODES.STORE_FAILURE, "boom", {
cause,
context: { id: "c-1" },
});
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ id: "c-1" });
});
it("supports switch discrimination on code", { timeout: 5000 }, () => {
const err = new CampaignsError(
CAMPAIGNS_CODES.INVALID_TRANSITION,
"bad"
);
let hit = false;
switch (err.code) {
case CAMPAIGNS_CODES.INVALID_TRANSITION:
hit = true;
break;
default:
hit = false;
}
expect(hit).toBe(true);
});
});

View File

@@ -0,0 +1,64 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/campaigns`.
*
* Every failure path in the package surfaces a `CampaignsError` whose
* `code` is drawn from this registry. Consumers SHOULD discriminate on
* `error.code` rather than `instanceof` so the namespace survives module
* boundaries and runtime serialisation (see `@suaveplan/error`).
*
* The registry covers Zod boundary failures (`INVALID_OPTIONS`,
* `INVALID_INPUT`), illegal state-machine transitions
* (`INVALID_TRANSITION`), missing-record lookups (`CAMPAIGN_NOT_FOUND`),
* store contract violations (`STORE_FAILURE`), batch planner argument
* validation (`INVALID_BATCH_CONFIG`, `INVALID_THROTTLE_CONFIG`), and
* workflow execution failures (`WORKFLOW_STEP_FAILED`).
*/
export const CAMPAIGNS_CODES = defineCodes("CAMPAIGNS", [
"INVALID_OPTIONS",
"INVALID_INPUT",
"INVALID_TRANSITION",
"CAMPAIGN_NOT_FOUND",
"STORE_FAILURE",
"INVALID_BATCH_CONFIG",
"INVALID_THROTTLE_CONFIG",
"WORKFLOW_STEP_FAILED",
] as const);
/** Discriminating union literal for {@link CampaignsError.code}. */
export type CampaignsCode =
(typeof CAMPAIGNS_CODES)[keyof typeof CAMPAIGNS_CODES];
/**
* Typed error for every `@suaveplan/campaigns` failure path.
*
* Extends `SuaveplanError<CampaignsCode>` so the `code` field narrows
* against the literal union and consumers get full type-checking on
* `switch (err.code)` blocks. Carries a public constructor so that
* `new CampaignsError(...)` works at the call site — `SuaveplanError`'s
* own constructor is protected.
*
* @example
* ```ts
* import { CAMPAIGNS_CODES, CampaignsError } from "@suaveplan/campaigns";
*
* try {
* await engine.send("missing");
* } catch (err) {
* if (err instanceof CampaignsError && err.code === CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND) {
* console.error("no such campaign", err.context);
* }
* }
* ```
*/
export class CampaignsError extends SuaveplanError<CampaignsCode> {
public constructor(
code: CampaignsCode,
message: string,
options?: { cause?: unknown; context?: Record<string, unknown> }
) {
super(code, message, options);
this.name = "CampaignsError";
}
}

View File

@@ -0,0 +1,2 @@
export type { CampaignsCode } from "./error-codes.js";
export { CAMPAIGNS_CODES, CampaignsError } from "./error-codes.js";

View File

@@ -0,0 +1,54 @@
export type {
Batch,
BatchPlannerOptions,
} from "./batch-planner/batch-planner.js";
export { BatchPlanner } from "./batch-planner/batch-planner.js";
export type { CampaignEngineOptions } from "./engine/engine.js";
export { CampaignEngine } from "./engine/engine.js";
export type { CampaignsCode } from "./error-codes/error-codes.js";
export { CAMPAIGNS_CODES, CampaignsError } from "./error-codes/error-codes.js";
export type { CampaignEvent } from "./state-machine/state-machine.js";
export { CampaignStateMachine } from "./state-machine/state-machine.js";
export type {
CampaignStore,
InMemoryCampaignStoreOptions,
} from "./store/store.js";
export { InMemoryCampaignStore } from "./store/store.js";
export {
batchesPlannedCounter,
batchPlanSizeHistogram,
campaignFailuresCounter,
campaignSendsCounter,
campaignsCreatedCounter,
campaignTransitionsCounter,
recordBatchesPlanned,
recordBatchPlanSize,
recordCampaignCreated,
recordFailure,
recordSend,
recordSendDuration,
recordTransition,
sendDurationHistogram,
} from "./telemetry/telemetry.js";
export type {
ABTestConfig,
ABTestVariant,
AudienceConfig,
BatchConfig,
Campaign,
CampaignQuery,
CampaignSchedule,
CampaignStats,
CampaignStatus,
CampaignType,
CreateCampaignInput,
ThrottleConfig,
UpdateCampaignInput,
} from "./types/campaign.types.js";
export { DEFAULT_BATCH_CONFIG } from "./types/campaign.types.js";
export type {
CampaignWorkflowOptions,
WorkflowContext,
WorkflowStep,
} from "./workflow/workflow.js";
export { CampaignWorkflow } from "./workflow/workflow.js";

View File

@@ -0,0 +1,2 @@
export type { CampaignEvent } from "./state-machine.js";
export { CampaignStateMachine } from "./state-machine.js";

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,133 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
import { CampaignStateMachine } from "./state-machine.js";
describe("CampaignStateMachine", () => {
const sm = new CampaignStateMachine();
describe("transition", () => {
it("draft -> scheduled via schedule", { timeout: 5000 }, () => {
expect(sm.transition("draft", "schedule")).toBe("scheduled");
});
it("draft -> sending via send", { timeout: 5000 }, () => {
expect(sm.transition("draft", "send")).toBe("sending");
});
it("scheduled -> sending via start", { timeout: 5000 }, () => {
expect(sm.transition("scheduled", "start")).toBe("sending");
});
it("scheduled -> cancelled via cancel", { timeout: 5000 }, () => {
expect(sm.transition("scheduled", "cancel")).toBe("cancelled");
});
it("sending -> paused via pause", { timeout: 5000 }, () => {
expect(sm.transition("sending", "pause")).toBe("paused");
});
it("sending -> completed via complete", { timeout: 5000 }, () => {
expect(sm.transition("sending", "complete")).toBe("completed");
});
it("sending -> failed via fail", { timeout: 5000 }, () => {
expect(sm.transition("sending", "fail")).toBe("failed");
});
it("paused -> sending via resume", { timeout: 5000 }, () => {
expect(sm.transition("paused", "resume")).toBe("sending");
});
it("paused -> cancelled via cancel", { timeout: 5000 }, () => {
expect(sm.transition("paused", "cancel")).toBe("cancelled");
});
it("throws CampaignsError on invalid transition", {
timeout: 5000,
}, () => {
try {
sm.transition("draft", "pause");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(CampaignsError);
if (err instanceof CampaignsError) {
expect(err.code).toBe(CAMPAIGNS_CODES.INVALID_TRANSITION);
expect(err.context).toEqual({
from: "draft",
event: "pause",
});
}
}
});
it("throws from completed", { timeout: 5000 }, () => {
expect(() => sm.transition("completed", "send")).toThrow(
/Invalid transition/
);
});
it("throws from cancelled", { timeout: 5000 }, () => {
expect(() => sm.transition("cancelled", "resume")).toThrow(
/Invalid transition/
);
});
it("throws from failed", { timeout: 5000 }, () => {
expect(() => sm.transition("failed", "send")).toThrow(
/Invalid transition/
);
});
});
describe("canTransition", () => {
it("true for valid", { timeout: 5000 }, () => {
expect(sm.canTransition("draft", "schedule")).toBe(true);
expect(sm.canTransition("draft", "send")).toBe(true);
expect(sm.canTransition("sending", "pause")).toBe(true);
});
it("false for invalid", { timeout: 5000 }, () => {
expect(sm.canTransition("draft", "pause")).toBe(false);
expect(sm.canTransition("completed", "send")).toBe(false);
expect(sm.canTransition("failed", "resume")).toBe(false);
});
});
describe("getAvailableEvents", () => {
it("draft", { timeout: 5000 }, () => {
const events = sm.getAvailableEvents("draft");
expect(events).toEqual(
expect.arrayContaining(["schedule", "send"])
);
expect(events).toHaveLength(2);
});
it("scheduled", { timeout: 5000 }, () => {
const events = sm.getAvailableEvents("scheduled");
expect(events).toEqual(expect.arrayContaining(["start", "cancel"]));
expect(events).toHaveLength(2);
});
it("sending", { timeout: 5000 }, () => {
const events = sm.getAvailableEvents("sending");
expect(events).toEqual(
expect.arrayContaining(["pause", "complete", "fail"])
);
expect(events).toHaveLength(3);
});
it("paused", { timeout: 5000 }, () => {
const events = sm.getAvailableEvents("paused");
expect(events).toEqual(
expect.arrayContaining(["resume", "cancel"])
);
expect(events).toHaveLength(2);
});
it("terminal states are empty", { timeout: 5000 }, () => {
expect(sm.getAvailableEvents("completed")).toHaveLength(0);
expect(sm.getAvailableEvents("cancelled")).toHaveLength(0);
expect(sm.getAvailableEvents("failed")).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,85 @@
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
import { recordTransition } from "../telemetry/telemetry.js";
import type { CampaignStatus } from "../types/campaign.types.js";
/**
* Discrete lifecycle events that can be applied to a campaign's status to
* transition it to a new status.
*/
export type CampaignEvent =
| "schedule"
| "send"
| "start"
| "cancel"
| "pause"
| "complete"
| "fail"
| "resume";
interface Transition {
readonly from: CampaignStatus;
readonly event: CampaignEvent;
readonly to: CampaignStatus;
}
const TRANSITIONS: readonly Transition[] = [
{ from: "draft", event: "schedule", to: "scheduled" },
{ from: "draft", event: "send", to: "sending" },
{ from: "scheduled", event: "start", to: "sending" },
{ from: "scheduled", event: "cancel", to: "cancelled" },
{ from: "sending", event: "pause", to: "paused" },
{ from: "sending", event: "complete", to: "completed" },
{ from: "sending", event: "fail", to: "failed" },
{ from: "paused", event: "resume", to: "sending" },
{ from: "paused", event: "cancel", to: "cancelled" },
];
/**
* Validates and executes campaign-status transitions.
*
* Use {@link transition} to apply an event or throw on invalid input,
* {@link canTransition} for predicate-only checks (UI affordances), and
* {@link getAvailableEvents} to drive menus / action buttons.
*/
export class CampaignStateMachine {
/**
* Apply `event` to `currentStatus` and return the resulting status.
*
* @throws {CampaignsError} with code `CAMPAIGNS.INVALID_TRANSITION` if
* no transition rule matches.
*/
public transition(
currentStatus: CampaignStatus,
event: CampaignEvent
): CampaignStatus {
const match = TRANSITIONS.find(
(t) => t.from === currentStatus && t.event === event
);
if (!match) {
throw new CampaignsError(
CAMPAIGNS_CODES.INVALID_TRANSITION,
`Invalid transition: cannot apply '${event}' to status '${currentStatus}'`,
{ context: { from: currentStatus, event } }
);
}
recordTransition(match.from, match.to, match.event);
return match.to;
}
/** Returns `true` if applying `event` to `currentStatus` is legal. */
public canTransition(
currentStatus: CampaignStatus,
event: CampaignEvent
): boolean {
return TRANSITIONS.some(
(t) => t.from === currentStatus && t.event === event
);
}
/** Returns every event legal in `currentStatus`. */
public getAvailableEvents(currentStatus: CampaignStatus): CampaignEvent[] {
return TRANSITIONS.filter((t) => t.from === currentStatus).map(
(t) => t.event
);
}
}

View File

@@ -0,0 +1,2 @@
export type { CampaignStore, InMemoryCampaignStoreOptions } from "./store.js";
export { InMemoryCampaignStore } from "./store.js";

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,159 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
import type { Campaign } from "../types/campaign.types.js";
import { InMemoryCampaignStore } from "./store.js";
function makeCampaign(overrides: Partial<Campaign> = {}): Campaign {
return {
id: "cmp-1",
tenantId: "tenant-1",
name: "Test",
status: "draft",
type: "broadcast",
channel: "email",
audienceConfig: { groupIds: ["g"] },
metadata: {},
stats: {
totalRecipients: 0,
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
unsubscribed: 0,
complained: 0,
failed: 0,
},
createdAt: new Date(0),
updatedAt: new Date(0),
...overrides,
};
}
describe("InMemoryCampaignStore", () => {
it("create + getById round-trip", { timeout: 5000 }, async () => {
const store = new InMemoryCampaignStore();
const created = await store.create(makeCampaign());
expect(created.id).toBe("cmp-1");
const fetched = await store.getById("cmp-1");
expect(fetched).toEqual(created);
});
it("getById returns null for missing", { timeout: 5000 }, async () => {
const store = new InMemoryCampaignStore();
expect(await store.getById("nope")).toBeNull();
});
it("update mutates fields + bumps updatedAt", {
timeout: 5000,
}, async () => {
let now = 1000;
const store = new InMemoryCampaignStore({ clock: () => now });
await store.create(makeCampaign());
now = 2000;
const updated = await store.update("cmp-1", { name: "renamed" });
expect(updated.name).toBe("renamed");
expect(updated.updatedAt.getTime()).toBe(2000);
});
it("update throws on missing", { timeout: 5000 }, async () => {
const store = new InMemoryCampaignStore();
try {
await store.update("nope", { name: "x" });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(CampaignsError);
if (err instanceof CampaignsError) {
expect(err.code).toBe(CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND);
}
}
});
it("delete returns true/false", { timeout: 5000 }, async () => {
const store = new InMemoryCampaignStore();
await store.create(makeCampaign());
expect(await store.delete("cmp-1")).toBe(true);
expect(await store.delete("cmp-1")).toBe(false);
});
it("list filters by tenantId/status/type/channel + paginates", {
timeout: 5000,
}, async () => {
const store = new InMemoryCampaignStore();
await store.create(
makeCampaign({ id: "a", tenantId: "t1", channel: "email" })
);
await store.create(
makeCampaign({ id: "b", tenantId: "t1", channel: "sms" })
);
await store.create(
makeCampaign({
id: "c",
tenantId: "t2",
status: "sending",
type: "ab_test",
})
);
expect(await store.list({ tenantId: "t1" })).toHaveLength(2);
expect(await store.list({ status: "sending" })).toHaveLength(1);
expect(await store.list({ type: "ab_test" })).toHaveLength(1);
expect(await store.list({ channel: "sms" })).toHaveLength(1);
expect(await store.list({ limit: 1, offset: 1 })).toHaveLength(1);
});
it("updateStats merges partial", { timeout: 5000 }, async () => {
const store = new InMemoryCampaignStore();
await store.create(makeCampaign());
const updated = await store.updateStats("cmp-1", {
sent: 5,
delivered: 4,
});
expect(updated.stats.sent).toBe(5);
expect(updated.stats.delivered).toBe(4);
expect(updated.stats.opened).toBe(0);
});
it("updateStats throws on missing", { timeout: 5000 }, async () => {
const store = new InMemoryCampaignStore();
await expect(store.updateStats("nope", { sent: 1 })).rejects.toThrow(
CampaignsError
);
});
it("updateStatus sets status + bumps updatedAt", {
timeout: 5000,
}, async () => {
const store = new InMemoryCampaignStore();
await store.create(makeCampaign());
const updated = await store.updateStatus("cmp-1", "sending");
expect(updated.status).toBe("sending");
});
it("updateStatus throws on missing", { timeout: 5000 }, async () => {
const store = new InMemoryCampaignStore();
await expect(
store.updateStatus("nope", "sending")
).rejects.toBeInstanceOf(CampaignsError);
});
it("default clock works (Date.now)", { timeout: 5000 }, async () => {
const store = new InMemoryCampaignStore();
await store.create(makeCampaign());
const before = Date.now();
const updated = await store.update("cmp-1", { name: "x" });
const after = Date.now();
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(before);
expect(updated.updatedAt.getTime()).toBeLessThanOrEqual(after);
});
it("returned objects are defensively cloned", {
timeout: 5000,
}, async () => {
const store = new InMemoryCampaignStore();
const created = await store.create(makeCampaign());
created.name = "tampered";
const fetched = await store.getById("cmp-1");
expect(fetched?.name).toBe("Test");
});
});

View File

@@ -0,0 +1,133 @@
import type { Clock } from "@suaveplan/types";
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
import type {
Campaign,
CampaignQuery,
CampaignStats,
CampaignStatus,
UpdateCampaignInput,
} from "../types/campaign.types.js";
/**
* Persistence contract for campaigns. Implementations may be in-memory
* (see {@link InMemoryCampaignStore}), SurrealDB-backed, or Cloudflare
* Durable-Object-backed — the engine is store-agnostic.
*/
export interface CampaignStore {
create(campaign: Campaign): Promise<Campaign>;
getById(id: string): Promise<Campaign | null>;
update(id: string, input: UpdateCampaignInput): Promise<Campaign>;
delete(id: string): Promise<boolean>;
list(query: CampaignQuery): Promise<Campaign[]>;
updateStats(id: string, stats: Partial<CampaignStats>): Promise<Campaign>;
updateStatus(id: string, status: CampaignStatus): Promise<Campaign>;
}
/** Options for {@link InMemoryCampaignStore}. */
export interface InMemoryCampaignStoreOptions {
/** Injectable wall-clock used to stamp `updatedAt`. Defaults to `Date.now`. */
readonly clock?: Clock;
}
/**
* Reference {@link CampaignStore} implementation backed by a `Map`. Suitable
* for unit tests, prototype workloads, and the default in-process engine.
*
* Every mutation returns a defensive clone so callers cannot tamper with
* internal records by reference.
*/
export class InMemoryCampaignStore implements CampaignStore {
private readonly campaigns = new Map<string, Campaign>();
private readonly clock: Clock;
public constructor(options: InMemoryCampaignStoreOptions = {}) {
this.clock = options.clock ?? (() => Date.now());
}
public async create(campaign: Campaign): Promise<Campaign> {
this.campaigns.set(campaign.id, { ...campaign });
return { ...campaign };
}
public async getById(id: string): Promise<Campaign | null> {
const campaign = this.campaigns.get(id);
return campaign ? { ...campaign } : null;
}
public async update(
id: string,
input: UpdateCampaignInput
): Promise<Campaign> {
const existing = this.requireExisting(id);
const updated: Campaign = {
...existing,
...input,
updatedAt: new Date(this.clock()),
};
this.campaigns.set(id, updated);
return { ...updated };
}
public async delete(id: string): Promise<boolean> {
return this.campaigns.delete(id);
}
public async list(query: CampaignQuery): Promise<Campaign[]> {
let results = Array.from(this.campaigns.values());
if (query.tenantId !== undefined) {
results = results.filter((c) => c.tenantId === query.tenantId);
}
if (query.status !== undefined) {
results = results.filter((c) => c.status === query.status);
}
if (query.type !== undefined) {
results = results.filter((c) => c.type === query.type);
}
if (query.channel !== undefined) {
results = results.filter((c) => c.channel === query.channel);
}
const offset = query.offset ?? 0;
const limit = query.limit ?? 50;
return results.slice(offset, offset + limit).map((c) => ({ ...c }));
}
public async updateStats(
id: string,
stats: Partial<CampaignStats>
): Promise<Campaign> {
const existing = this.requireExisting(id);
const updated: Campaign = {
...existing,
stats: { ...existing.stats, ...stats },
updatedAt: new Date(this.clock()),
};
this.campaigns.set(id, updated);
return { ...updated };
}
public async updateStatus(
id: string,
status: CampaignStatus
): Promise<Campaign> {
const existing = this.requireExisting(id);
const updated: Campaign = {
...existing,
status,
updatedAt: new Date(this.clock()),
};
this.campaigns.set(id, updated);
return { ...updated };
}
private requireExisting(id: string): Campaign {
const existing = this.campaigns.get(id);
if (!existing) {
throw new CampaignsError(
CAMPAIGNS_CODES.CAMPAIGN_NOT_FOUND,
`Campaign not found: ${id}`,
{ context: { id } }
);
}
return existing;
}
}

View File

@@ -0,0 +1,16 @@
export {
batchesPlannedCounter,
batchPlanSizeHistogram,
campaignFailuresCounter,
campaignSendsCounter,
campaignsCreatedCounter,
campaignTransitionsCounter,
recordBatchesPlanned,
recordBatchPlanSize,
recordCampaignCreated,
recordFailure,
recordSend,
recordSendDuration,
recordTransition,
sendDurationHistogram,
} from "./telemetry.js";

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,43 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
batchesPlannedCounter,
batchPlanSizeHistogram,
campaignFailuresCounter,
campaignSendsCounter,
campaignsCreatedCounter,
campaignTransitionsCounter,
recordBatchesPlanned,
recordBatchPlanSize,
recordCampaignCreated,
recordFailure,
recordSend,
recordSendDuration,
recordTransition,
sendDurationHistogram,
} from "./telemetry.js";
describe("campaigns telemetry", () => {
it("exposes counters + histograms", { timeout: 5000 }, () => {
expect(campaignsCreatedCounter).toBeDefined();
expect(campaignTransitionsCounter).toBeDefined();
expect(campaignSendsCounter).toBeDefined();
expect(batchesPlannedCounter).toBeDefined();
expect(campaignFailuresCounter).toBeDefined();
expect(batchPlanSizeHistogram).toBeDefined();
expect(sendDurationHistogram).toBeDefined();
});
it("every record helper is callable without a registered provider", {
timeout: 5000,
}, () => {
expect(() => recordCampaignCreated("broadcast", "email")).not.toThrow();
expect(() =>
recordTransition("draft", "sending", "send")
).not.toThrow();
expect(() => recordSend("email")).not.toThrow();
expect(() => recordBatchesPlanned(3)).not.toThrow();
expect(() => recordFailure("email")).not.toThrow();
expect(() => recordBatchPlanSize(1000)).not.toThrow();
expect(() => recordSendDuration(42)).not.toThrow();
});
});

View File

@@ -0,0 +1,113 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/campaigns";
/**
* Lazy-bound meter — resolves the globally registered `MeterProvider` on
* first access. When no provider is registered the OTel API returns a
* no-op meter, so instruments are safe to import in production code that
* does not opt into telemetry.
*/
const meter = metrics.getMeter(METER_NAME);
/** Counter for created campaigns. */
export const campaignsCreatedCounter: Counter = meter.createCounter(
"campaigns_created_total",
{
description:
"Total campaigns created via CampaignEngine.create(), tagged by type/channel",
}
);
/** Counter for campaign lifecycle transitions. */
export const campaignTransitionsCounter: Counter = meter.createCounter(
"campaigns_transitions_total",
{
description:
"Total successful campaign state-machine transitions, tagged by from/to/event",
}
);
/** Counter for campaign send dispatches. */
export const campaignSendsCounter: Counter = meter.createCounter(
"campaigns_sends_total",
{
description:
"Total campaign sends initiated via CampaignEngine.send(), tagged by channel",
}
);
/** Counter for batches emitted by the BatchPlanner. */
export const batchesPlannedCounter: Counter = meter.createCounter(
"campaigns_batches_planned_total",
{
description: "Total batches produced by BatchPlanner.planBatches()",
}
);
/** Counter for campaign failures. */
export const campaignFailuresCounter: Counter = meter.createCounter(
"campaigns_failures_total",
{
description:
"Total campaign failures registered via CampaignEngine.fail()",
}
);
/** Histogram of total recipients per batch plan. */
export const batchPlanSizeHistogram: Histogram = meter.createHistogram(
"campaigns_batch_plan_size",
{
description: "Total contact count fed into a single planBatches() call",
unit: "{contact}",
}
);
/** Histogram of computed total send duration in milliseconds. */
export const sendDurationHistogram: Histogram = meter.createHistogram(
"campaigns_send_duration_ms",
{
description:
"Estimated total send-window duration in milliseconds, recorded on planBatches()/calculateTotalDuration()",
unit: "ms",
}
);
/** Record one created-campaign event. */
export function recordCampaignCreated(type: string, channel: string): void {
campaignsCreatedCounter.add(1, { type, channel });
}
/** Record one successful state transition. */
export function recordTransition(
from: string,
to: string,
event: string
): void {
campaignTransitionsCounter.add(1, { from, to, event });
}
/** Record one send-dispatch event. */
export function recordSend(channel: string): void {
campaignSendsCounter.add(1, { channel });
}
/** Record one batch-plan emission, attaching the batch count. */
export function recordBatchesPlanned(batchCount: number): void {
batchesPlannedCounter.add(batchCount);
}
/** Record one fail-dispatch event. */
export function recordFailure(channel: string): void {
campaignFailuresCounter.add(1, { channel });
}
/** Record the total-recipient size of one plan. */
export function recordBatchPlanSize(totalContacts: number): void {
batchPlanSizeHistogram.record(totalContacts);
}
/** Record an estimated send-window duration in milliseconds. */
export function recordSendDuration(durationMs: number): void {
sendDurationHistogram.record(durationMs);
}

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,150 @@
/**
* Lifecycle status for a campaign.
*
* - `draft` — created but not yet scheduled or sent.
* - `scheduled` — awaiting a future send time.
* - `sending` — actively dispatching batches.
* - `paused` — temporarily halted; can resume.
* - `completed` — terminal; all batches dispatched.
* - `cancelled` — terminal; user-cancelled before completion.
* - `failed` — terminal; aborted due to an error.
*/
export type CampaignStatus =
| "draft"
| "scheduled"
| "sending"
| "paused"
| "completed"
| "cancelled"
| "failed";
/** High-level send pattern for a campaign. */
export type CampaignType = "broadcast" | "triggered" | "ab_test" | "drip";
/** Audience targeting configuration. */
export interface AudienceConfig {
readonly groupIds: readonly string[];
readonly excludeGroupIds?: readonly string[];
readonly excludeContactIds?: readonly string[];
readonly segmentIds?: readonly string[];
readonly suppressionListIds?: readonly string[];
}
/** When and how a campaign should be dispatched. */
export interface CampaignSchedule {
readonly type: "immediate" | "scheduled" | "recurring";
readonly sendAt?: Date;
readonly cronExpression?: string;
readonly timezone?: string;
}
/** Throttle controls for batch delivery. */
export interface ThrottleConfig {
readonly messagesPerSecond?: number;
readonly messagesPerMinute?: number;
readonly messagesPerHour?: number;
readonly rampUp?: boolean;
readonly rampUpDuration?: number;
}
/** Single variant in an A/B test configuration. */
export interface ABTestVariant {
readonly id: string;
readonly name: string;
readonly templateId: string;
readonly templateData?: Record<string, unknown>;
readonly weight?: number;
}
/** Full A/B test configuration for a campaign. */
export interface ABTestConfig {
readonly variants: readonly ABTestVariant[];
readonly splitType: "even" | "weighted";
readonly testDuration?: number;
readonly winnerCriteria?: "open_rate" | "click_rate" | "conversion";
}
/** Aggregate delivery + engagement statistics for a campaign. */
export interface CampaignStats {
totalRecipients: number;
sent: number;
delivered: number;
opened: number;
clicked: number;
bounced: number;
unsubscribed: number;
complained: number;
failed: number;
}
/** Full campaign object as stored. */
export interface Campaign<TMeta = Record<string, unknown>> {
readonly id: string;
readonly tenantId: string;
name: string;
status: CampaignStatus;
type: CampaignType;
channel: string;
audienceConfig: AudienceConfig;
templateId?: string;
templateData?: Record<string, unknown>;
schedule?: CampaignSchedule;
abTestConfig?: ABTestConfig;
throttle?: ThrottleConfig;
metadata: TMeta;
stats: CampaignStats;
readonly createdAt: Date;
updatedAt: Date;
sentAt?: Date;
completedAt?: Date;
}
/** Input for creating a new campaign. */
export interface CreateCampaignInput<TMeta = Record<string, unknown>> {
readonly tenantId: string;
readonly name: string;
readonly type: CampaignType;
readonly channel: string;
readonly audienceConfig: AudienceConfig;
readonly templateId?: string;
readonly templateData?: Record<string, unknown>;
readonly schedule?: CampaignSchedule;
readonly abTestConfig?: ABTestConfig;
readonly throttle?: ThrottleConfig;
readonly metadata?: TMeta;
}
/** Input for updating an existing campaign. */
export interface UpdateCampaignInput<TMeta = Record<string, unknown>> {
readonly name?: string;
readonly channel?: string;
readonly audienceConfig?: AudienceConfig;
readonly templateId?: string;
readonly templateData?: Record<string, unknown>;
readonly schedule?: CampaignSchedule;
readonly abTestConfig?: ABTestConfig;
readonly throttle?: ThrottleConfig;
readonly metadata?: TMeta;
}
/** Filter parameters for listing campaigns. */
export interface CampaignQuery {
readonly tenantId?: string;
readonly status?: CampaignStatus;
readonly type?: CampaignType;
readonly channel?: string;
readonly limit?: number;
readonly offset?: number;
}
/** Batch sizing + delay configuration. */
export interface BatchConfig {
readonly batchSize: number;
readonly delayBetweenBatches: number;
}
/** Default batch configuration used when no overrides are supplied. */
export const DEFAULT_BATCH_CONFIG: BatchConfig = {
batchSize: 100,
delayBetweenBatches: 0,
};

View File

@@ -0,0 +1,16 @@
export type {
ABTestConfig,
ABTestVariant,
AudienceConfig,
BatchConfig,
Campaign,
CampaignQuery,
CampaignSchedule,
CampaignStats,
CampaignStatus,
CampaignType,
CreateCampaignInput,
ThrottleConfig,
UpdateCampaignInput,
} from "./campaign.types.js";
export { DEFAULT_BATCH_CONFIG } from "./campaign.types.js";

View File

@@ -0,0 +1,6 @@
export type {
CampaignWorkflowOptions,
WorkflowContext,
WorkflowStep,
} from "./workflow.js";
export { CampaignWorkflow } from "./workflow.js";

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.

View File

@@ -0,0 +1,215 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
import type { Campaign } from "../types/campaign.types.js";
import {
CampaignWorkflow,
type WorkflowContext,
type WorkflowStep,
} from "./workflow.js";
function makeCampaign(): Campaign {
return {
id: "cmp-1",
tenantId: "tenant-1",
name: "Test Campaign",
status: "sending",
type: "broadcast",
channel: "email",
audienceConfig: { groupIds: ["group-1"] },
metadata: {},
stats: {
totalRecipients: 0,
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
unsubscribed: 0,
complained: 0,
failed: 0,
},
createdAt: new Date(0),
updatedAt: new Date(0),
};
}
describe("CampaignWorkflow", () => {
const workflow = new CampaignWorkflow();
describe("buildSendPipeline", () => {
it("returns 6 steps in canonical order", { timeout: 5000 }, () => {
const steps = workflow.buildSendPipeline(makeCampaign());
expect(steps.map((s) => s.id)).toEqual([
"resolve-audience",
"apply-suppression",
"plan-batches",
"send-batches",
"update-stats",
"complete",
]);
});
it("every step has name + description + execute", {
timeout: 5000,
}, () => {
for (const step of workflow.buildSendPipeline(makeCampaign())) {
expect(typeof step.execute).toBe("function");
expect(step.name).toBeTruthy();
expect(step.description).toBeTruthy();
}
});
it("execute returns enriched context", {
timeout: 5000,
}, async () => {
const c = makeCampaign();
const steps = workflow.buildSendPipeline(c);
const result = await steps[0].execute({ campaign: c });
expect(result.contactIds).toEqual([]);
});
it("apply-suppression preserves existing contactIds", {
timeout: 5000,
}, async () => {
const c = makeCampaign();
const steps = workflow.buildSendPipeline(c);
const result = await steps[1].execute({
campaign: c,
contactIds: ["a", "b"],
});
expect(result.contactIds).toEqual(["a", "b"]);
});
it("apply-suppression defaults to [] when no contactIds", {
timeout: 5000,
}, async () => {
const c = makeCampaign();
const steps = workflow.buildSendPipeline(c);
const result = await steps[1].execute({ campaign: c });
expect(result.contactIds).toEqual([]);
});
it("plan-batches preserves existing batches", {
timeout: 5000,
}, async () => {
const c = makeCampaign();
const steps = workflow.buildSendPipeline(c);
const existing = [{ batchIndex: 0, contactIds: ["a"] }];
const result = await steps[2].execute({
campaign: c,
batches: existing,
});
expect(result.batches).toEqual(existing);
});
it("plan-batches defaults to []", {
timeout: 5000,
}, async () => {
const c = makeCampaign();
const steps = workflow.buildSendPipeline(c);
const r = await steps[2].execute({ campaign: c });
expect(r.batches).toEqual([]);
});
it("send-batches/update-stats/complete pass context through", {
timeout: 5000,
}, async () => {
const c = makeCampaign();
const steps = workflow.buildSendPipeline(c);
const ctx = {
campaign: c,
contactIds: ["a"],
results: { ok: 1 },
};
for (const step of steps.slice(3)) {
const r = await step.execute(ctx);
expect(r.results).toEqual({ ok: 1 });
}
});
});
describe("run", () => {
it("executes every step in order", { timeout: 5000 }, async () => {
const order: string[] = [];
const steps: WorkflowStep[] = [
{
id: "a",
name: "A",
description: "first",
execute: async (ctx) => {
order.push("a");
return { ...ctx, results: { ...ctx.results, a: 1 } };
},
},
{
id: "b",
name: "B",
description: "second",
execute: async (ctx) => {
order.push("b");
return { ...ctx, results: { ...ctx.results, b: 2 } };
},
},
];
const result = await workflow.run(steps, {
campaign: makeCampaign(),
});
expect(order).toEqual(["a", "b"]);
expect(result.results).toEqual({ a: 1, b: 2 });
});
it("wraps step errors in WORKFLOW_STEP_FAILED", {
timeout: 5000,
}, async () => {
const cause = new Error("boom");
const steps: WorkflowStep[] = [
{
id: "broken",
name: "Broken",
description: "fails",
execute: async () => {
throw cause;
},
},
];
try {
await workflow.run(steps, { campaign: makeCampaign() });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(CampaignsError);
if (err instanceof CampaignsError) {
expect(err.code).toBe(CAMPAIGNS_CODES.WORKFLOW_STEP_FAILED);
expect(err.cause).toBe(cause);
expect(err.context?.stepId).toBe("broken");
}
}
});
it("logs step start/end when logger injected", {
timeout: 5000,
}, async () => {
const logs: string[] = [];
const wf = new CampaignWorkflow({
logger: {
debug: (m) => logs.push(m),
info: () => {},
warn: () => {},
error: () => {},
},
});
const ctx: WorkflowContext = { campaign: makeCampaign() };
await wf.run(
[
{
id: "x",
name: "X",
description: "x",
execute: async (c) => c,
},
],
ctx
);
expect(logs).toEqual(["workflow.step.start", "workflow.step.end"]);
});
});
});

View File

@@ -0,0 +1,127 @@
import type { LoggerLike } from "@suaveplan/types";
import type { Batch } from "../batch-planner/batch-planner.js";
import { CAMPAIGNS_CODES, CampaignsError } from "../error-codes/error-codes.js";
import type { Campaign } from "../types/campaign.types.js";
/** Context object threaded through every workflow step. */
export interface WorkflowContext {
readonly campaign: Campaign;
contactIds?: string[];
batches?: Batch[];
results?: Record<string, unknown>;
}
/** A single named step in a campaign send workflow. */
export interface WorkflowStep {
readonly id: string;
readonly name: string;
readonly description: string;
execute(context: WorkflowContext): Promise<WorkflowContext>;
}
/** Construction options for {@link CampaignWorkflow}. */
export interface CampaignWorkflowOptions {
/** Optional logger used to trace step execution. */
readonly logger?: LoggerLike;
}
/**
* Builds and runs the canonical send pipeline for a campaign.
*
* Step set is fixed (6 steps) but each step is an injectable async function
* — call sites can wrap the default pipeline to layer audience resolution,
* suppression filtering, batch planning, dispatch, stats roll-up, and
* completion.
*/
export class CampaignWorkflow {
private readonly logger?: LoggerLike;
public constructor(options: CampaignWorkflowOptions = {}) {
if (options.logger !== undefined) {
this.logger = options.logger;
}
}
/** Build a 6-step default send pipeline keyed by the campaign id. */
public buildSendPipeline(_campaign: Campaign): WorkflowStep[] {
return [
{
id: "resolve-audience",
name: "Resolve Audience",
description:
"Resolve audience groups to individual contact IDs",
execute: async (context) => ({
...context,
contactIds: context.contactIds ?? [],
}),
},
{
id: "apply-suppression",
name: "Apply Suppression",
description: "Filter out suppressed and excluded contacts",
execute: async (context) => ({
...context,
contactIds: context.contactIds ?? [],
}),
},
{
id: "plan-batches",
name: "Plan Batches",
description:
"Split contacts into send batches based on throttle config",
execute: async (context) => ({
...context,
batches: context.batches ?? [],
}),
},
{
id: "send-batches",
name: "Send Batches",
description:
"Process and send each batch through the configured channel",
execute: async (context) => context,
},
{
id: "update-stats",
name: "Update Stats",
description: "Update campaign statistics with send results",
execute: async (context) => context,
},
{
id: "complete",
name: "Complete Campaign",
description: "Mark the campaign as completed",
execute: async (context) => context,
},
];
}
/**
* Execute the supplied step list against `initial`. Returns the final
* context. Wraps any step error in a {@link CampaignsError} with code
* `WORKFLOW_STEP_FAILED` so callers can switch on the canonical
* registry instead of `instanceof` ladders.
*/
public async run(
steps: readonly WorkflowStep[],
initial: WorkflowContext
): Promise<WorkflowContext> {
let ctx = initial;
for (const step of steps) {
try {
this.logger?.debug("workflow.step.start", {
id: step.id,
});
ctx = await step.execute(ctx);
this.logger?.debug("workflow.step.end", { id: step.id });
} catch (cause) {
throw new CampaignsError(
CAMPAIGNS_CODES.WORKFLOW_STEP_FAILED,
`Workflow step '${step.id}' failed`,
{ cause, context: { stepId: step.id } }
);
}
}
return ctx;
}
}

View File

@@ -0,0 +1 @@
export {};

View File

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

View File

@@ -0,0 +1,54 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir:
"../../../node_modules/.vite/packages/communication-emails-campaigns",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"engine/index": "src/engine/index.ts",
"state-machine/index": "src/state-machine/index.ts",
"batch-planner/index": "src/batch-planner/index.ts",
"workflow/index": "src/workflow/index.ts",
"store/index": "src/store/index.ts",
"types/index": "src/types/index.ts",
"error-codes/index": "src/error-codes/index.ts",
"telemetry/index": "src/telemetry/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/logger",
"@suaveplan/opentelemetry",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

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