85 lines
7.0 KiB
Markdown
85 lines
7.0 KiB
Markdown
# Spec Delta: campaigns
|
|
|
|
## ADDED Requirements
|
|
|
|
### Requirement: CampaignEngine SHALL orchestrate campaign lifecycle CRUD
|
|
|
|
`CampaignEngine` exposes `create`, `schedule`, `send`, `pause`, `resume`, `cancel`, `complete`, `fail`, `duplicate`, `updateStats`, `getById`, and `list` methods. Each method delegates persistence to an injectable `CampaignStore` and state validation to `CampaignStateMachine`. The engine accepts injectable `Clock`, `IdGenerator`, and `LoggerLike` so it is deterministic under test.
|
|
|
|
**Verified by:** `src/engine/engine.test.ts` exercises every method with `InMemoryCampaignStore`, a fixed `Clock`, and a deterministic `IdGenerator`, including the not-found path, the invalid-transition path, and the with/without-reason `fail` branches.
|
|
|
|
#### Scenario: Create + send
|
|
|
|
- WHEN `engine.create({tenantId, name, type:"broadcast", channel:"email", audienceConfig})` is called THEN a campaign in `draft` status is persisted and returned.
|
|
- WHEN `engine.send(id)` is called on a draft THEN the status transitions to `sending` and `campaigns_sends_total` is incremented.
|
|
|
|
#### Scenario: Schedule a draft
|
|
|
|
- WHEN `engine.schedule(id, {type:"scheduled", sendAt})` is called on a draft THEN the schedule is persisted and status transitions to `scheduled`.
|
|
- WHEN `engine.schedule(id, schedule)` is called on a non-draft campaign THEN `CampaignsError(INVALID_TRANSITION)` is thrown.
|
|
|
|
#### Scenario: Fail with reason
|
|
|
|
- WHEN `engine.fail(id, "Provider error")` is called on a sending campaign THEN `metadata.failureReason` is set and status transitions to `failed`.
|
|
|
|
### Requirement: CampaignStateMachine SHALL enforce exactly nine canonical transitions
|
|
|
|
The state machine accepts only the following `from -> to` transitions via the listed events: `draft -> scheduled (schedule)`, `draft -> sending (send)`, `scheduled -> sending (start)`, `scheduled -> cancelled (cancel)`, `sending -> paused (pause)`, `sending -> completed (complete)`, `sending -> failed (fail)`, `paused -> sending (resume)`, `paused -> cancelled (cancel)`. Every other combination throws `CampaignsError(INVALID_TRANSITION)`.
|
|
|
|
**Verified by:** `src/state-machine/state-machine.test.ts` covers every valid transition and the four terminal-state rejection paths (`draft+pause`, `completed+send`, `cancelled+resume`, `failed+send`).
|
|
|
|
#### Scenario: Invalid transition
|
|
|
|
- WHEN `sm.transition("draft", "pause")` is called THEN `CampaignsError(INVALID_TRANSITION)` is thrown with `context: { from:"draft", event:"pause" }`.
|
|
|
|
#### Scenario: Available events
|
|
|
|
- WHEN `sm.getAvailableEvents("sending")` is called THEN `["pause", "complete", "fail"]` is returned (in some order).
|
|
- WHEN `sm.getAvailableEvents("completed")` is called THEN `[]` is returned.
|
|
|
|
### Requirement: BatchPlanner SHALL produce throttle-aware send plans
|
|
|
|
`BatchPlanner.planBatches(contactIds, config?, throttle?)` splits the contact list into batches sized by `config.batchSize` (default 100). The first batch has `scheduledAt === undefined`; each subsequent batch's `scheduledAt` is `clock() + batchIndex * effectiveDelay`. `effectiveDelay` is the maximum of `config.delayBetweenBatches` and the throttle-derived inter-batch delay (`messagesPerSecond`, `messagesPerMinute`, or `messagesPerHour`, in priority order).
|
|
|
|
**Verified by:** `src/batch-planner/batch-planner.test.ts` covers empty input, single-batch output, multi-batch splits, every throttle variant, and the precedence rule between explicit `delayBetweenBatches` and throttle-derived delay.
|
|
|
|
#### Scenario: messagesPerSecond throttle
|
|
|
|
- WHEN `planBatches(contactIds, {batchSize:100}, {messagesPerSecond:50})` is called with 200 contacts THEN two batches are returned and `batches[1].scheduledAt` is exactly `clock() + 2000`.
|
|
|
|
#### Scenario: Invalid throttle
|
|
|
|
- WHEN `planBatches(ids, {batchSize:10}, {messagesPerSecond:-1})` is called THEN `CampaignsError(INVALID_THROTTLE_CONFIG)` is thrown.
|
|
|
|
### Requirement: CampaignWorkflow SHALL execute a six-step send pipeline with wrapped errors
|
|
|
|
`CampaignWorkflow.buildSendPipeline(campaign)` returns six steps in the canonical order: `resolve-audience`, `apply-suppression`, `plan-batches`, `send-batches`, `update-stats`, `complete`. `CampaignWorkflow.run(steps, initial)` executes the steps sequentially, threads the result of each into the next, and wraps any step error in `CampaignsError(WORKFLOW_STEP_FAILED)` with `cause` preserved and `context.stepId` set to the failing step id.
|
|
|
|
**Verified by:** `src/workflow/workflow.test.ts` asserts the six-step ordering, executable contract, context threading, the wrapped-error invariant, and the optional logger debug trace.
|
|
|
|
#### Scenario: Wrapped step error
|
|
|
|
- WHEN a step's `execute` throws `cause` THEN `run` throws `CampaignsError(WORKFLOW_STEP_FAILED)` with `err.cause === cause` and `err.context.stepId === step.id`.
|
|
|
|
### Requirement: CampaignStore SHALL define the persistence contract; InMemoryCampaignStore SHALL implement it
|
|
|
|
The `CampaignStore` interface has seven methods: `create`, `getById`, `update`, `delete`, `list` (with `tenantId`/`status`/`type`/`channel`/`limit`/`offset` filters), `updateStats`, and `updateStatus`. `InMemoryCampaignStore` implements the contract over a `Map<string, Campaign>`, accepts an injectable `Clock`, returns defensive clones on every read/write, and throws `CampaignsError(CAMPAIGN_NOT_FOUND)` when mutating a missing id.
|
|
|
|
**Verified by:** `src/store/store.test.ts` exercises every method, including the missing-record error path on `update`/`updateStats`/`updateStatus`, the defensive-clone invariant, and the four list-filter dimensions plus pagination.
|
|
|
|
#### Scenario: Defensive clone
|
|
|
|
- WHEN a caller mutates the object returned from `store.create(campaign)` THEN a subsequent `store.getById(id)` is unaffected.
|
|
|
|
### Requirement: Errors SHALL flow through CampaignsError with CAMPAIGNS_CODES
|
|
|
|
Every failure path raised by the package produces a `CampaignsError` whose `code` is drawn from `CAMPAIGNS_CODES`. The registry contains: `INVALID_OPTIONS`, `INVALID_INPUT`, `INVALID_TRANSITION`, `CAMPAIGN_NOT_FOUND`, `STORE_FAILURE`, `INVALID_BATCH_CONFIG`, `INVALID_THROTTLE_CONFIG`, `WORKFLOW_STEP_FAILED`. `CampaignsError` exposes an explicit public constructor so call sites can instantiate it directly.
|
|
|
|
**Verified by:** `src/error-codes/error-codes.test.ts` covers the eight registered codes and the public-constructor invariant; every other test file asserts the expected `code` on its thrown `CampaignsError` instances.
|
|
|
|
### Requirement: OTel self-telemetry SHALL be emitted from every meaningful operation
|
|
|
|
The package emits five counters (`campaigns_created_total`, `campaigns_transitions_total`, `campaigns_sends_total`, `campaigns_batches_planned_total`, `campaigns_failures_total`) and two histograms (`campaigns_batch_plan_size`, `campaigns_send_duration_ms`) via `@opentelemetry/api`. Counters carry only low-cardinality labels (`type`, `channel`, `from`, `to`, `event`). All record helpers are safe to call when no `MeterProvider` is registered.
|
|
|
|
**Verified by:** `src/telemetry/telemetry.test.ts` asserts every counter/histogram is defined and every record helper runs without throwing under the default no-op provider.
|