7.0 KiB
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 indraftstatus is persisted and returned. - WHEN
engine.send(id)is called on a draft THEN the status transitions tosendingandcampaigns_sends_totalis 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 toscheduled. - WHEN
engine.schedule(id, schedule)is called on a non-draft campaign THENCampaignsError(INVALID_TRANSITION)is thrown.
Scenario: Fail with reason
- WHEN
engine.fail(id, "Provider error")is called on a sending campaign THENmetadata.failureReasonis set and status transitions tofailed.
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 THENCampaignsError(INVALID_TRANSITION)is thrown withcontext: { 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 andbatches[1].scheduledAtis exactlyclock() + 2000.
Scenario: Invalid throttle
- WHEN
planBatches(ids, {batchSize:10}, {messagesPerSecond:-1})is called THENCampaignsError(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
executethrowscauseTHENrunthrowsCampaignsError(WORKFLOW_STEP_FAILED)witherr.cause === causeanderr.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 subsequentstore.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.