Files
2026-05-27 23:29:53 +01:00

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