Initial Commit
This commit is contained in:
48
openspec/archive/2026-05-24-campaigns/proposal.md
Normal file
48
openspec/archive/2026-05-24-campaigns/proposal.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Change: campaigns
|
||||
|
||||
- **Change ID**: campaigns
|
||||
- **Package**: `@suaveplan/campaigns`
|
||||
- **Path**: `packages/communication/emails/campaigns/`
|
||||
- **Tier**: universal
|
||||
- **Date**: 2026-05-24
|
||||
|
||||
## Summary
|
||||
|
||||
Land `@suaveplan/campaigns` on genesis at `packages/communication/emails/campaigns/`. The package combines audience targeting, template binding, channel dispatch, and schedule management into managed send workflows. It exposes a state-machine-driven `CampaignEngine`, a throttle-aware `BatchPlanner`, a composable six-step `CampaignWorkflow`, and a pluggable `CampaignStore` interface with an in-memory reference implementation.
|
||||
|
||||
## Why
|
||||
|
||||
`@suaveplan/campaigns` is the orchestration layer above `@suaveplan/emailer`, `@suaveplan/email-templates`, `@suaveplan/suppression`, and `@suaveplan/contacts`. Without it, callers have to hand-roll lifecycle FSMs and throttle math, which has caused drift between historical implementations.
|
||||
|
||||
## Scope
|
||||
|
||||
- **Engine**: `CampaignEngine` with `create`, `schedule`, `send`, `pause`, `resume`, `cancel`, `complete`, `fail`, `duplicate`, `updateStats`, `getById`, `list`. Injectable `Clock`/`IdGenerator`/`LoggerLike`.
|
||||
- **State machine**: `CampaignStateMachine` with nine canonical transitions, `transition`/`canTransition`/`getAvailableEvents`.
|
||||
- **Batch planner**: `BatchPlanner` with `planBatches` and `calculateTotalDuration`, throttle-aware delay calculation, injectable `Clock`.
|
||||
- **Workflow**: `CampaignWorkflow` with `buildSendPipeline` (6 steps) and `run` (sequential executor that wraps step errors in `CampaignsError(WORKFLOW_STEP_FAILED)`).
|
||||
- **Store**: `CampaignStore` interface + `InMemoryCampaignStore` reference implementation.
|
||||
- **Errors**: `CAMPAIGNS_CODES` (8 codes) + `CampaignsError` with explicit public ctor.
|
||||
- **Telemetry**: 5 counters + 2 histograms via `@opentelemetry/api`.
|
||||
- **Types**: 13 exported types/interfaces including `Campaign`, `CampaignStatus`, `CampaignType`, `AudienceConfig`, `ThrottleConfig`, `ABTestConfig`, `CampaignStats`, `CreateCampaignInput`, etc.
|
||||
|
||||
## Out of scope (deliberate)
|
||||
|
||||
None. The package lands at full functional completeness. Storage adapters (SurrealDB, Postgres) and the HTTP plugin live in sibling packages and are tracked by their own OpenSpec changes.
|
||||
|
||||
## Deviations from upstream
|
||||
|
||||
- Single-folder feature layout (`engine/`, `state-machine/`, `batch-planner/`, `workflow/`, `store/`, `types/`, `error-codes/`, `telemetry/`) instead of `core/<feature>/` nested under upstream's `core/`. Aligns with the 3-level package layout rule.
|
||||
- Errors thrown by every public method go through `CampaignsError` + `CAMPAIGNS_CODES` instead of raw `Error`.
|
||||
- Zod-validated boundaries on `CampaignEngineOptions`, `CreateCampaignInput`, `BatchPlannerOptions`, `BatchConfig`, `ThrottleConfig`.
|
||||
- OTel instrumentation added throughout (5 counters + 2 histograms).
|
||||
- Injectable `Clock`, `IdGenerator`, `LoggerLike` on engine; injectable `Clock` on batch planner and in-memory store.
|
||||
- `CampaignWorkflow.run` added so the package owns its own sequential executor.
|
||||
|
||||
## Verified by
|
||||
|
||||
- `bunx turbo lint typecheck test --filter=@suaveplan/campaigns` — green.
|
||||
- `bunx turbo test:coverage --filter=@suaveplan/campaigns` — 100/100/100/100 (statements/branches/functions/lines).
|
||||
- `bunx turbo build --filter=@suaveplan/campaigns` — emits `dist/`.
|
||||
- `bun scripts/lint/check-feature-layout.ts` — no campaigns violations.
|
||||
- `bun scripts/lint/check-test-runner-imports.ts` — no campaigns violations.
|
||||
- `grep -rn "biome-ignore\|TODO\|FIXME\|XXX" packages/communication/emails/campaigns/src/` — empty.
|
||||
@@ -0,0 +1,84 @@
|
||||
# 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.
|
||||
39
openspec/archive/2026-05-24-campaigns/tasks.md
Normal file
39
openspec/archive/2026-05-24-campaigns/tasks.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Tasks: campaigns
|
||||
|
||||
## §0 Pre-flight (NON-NEGOTIABLE)
|
||||
|
||||
- [x] Run `pwd && git worktree list && git status` — confirm we are in an isolated `.claude/worktrees/agent-*` worktree, not main.
|
||||
- [x] Sync via `git merge-base --is-ancestor` check + `git fetch /home/paul/web/genesis genesis && git merge FETCH_HEAD` if behind.
|
||||
|
||||
## §1 Skeleton
|
||||
|
||||
- [x] Create `packages/communication/emails/campaigns/` directory.
|
||||
- [x] Author `package.json` with all 9 subpath exports.
|
||||
- [x] Author `vite.config.ts`, `vitest.config.ts`, `tsconfig.lib.json`, `tests/setup/polyfills.ts`.
|
||||
- [x] Update `scripts/folder-reorg/assignment.yml` (move `campaigns` from `platform` → `emails`).
|
||||
|
||||
## §2 Source modules (5-file feature layout)
|
||||
|
||||
- [x] `src/types/campaign.types.ts` + `index.ts` (flat exemption).
|
||||
- [x] `src/error-codes/{error-codes.ts, error-codes.test.ts, error-codes.md, index.ts}`.
|
||||
- [x] `src/telemetry/{telemetry.ts, telemetry.test.ts, telemetry.md, index.ts}`.
|
||||
- [x] `src/state-machine/{state-machine.ts, state-machine.test.ts, state-machine.md, index.ts}`.
|
||||
- [x] `src/batch-planner/{batch-planner.ts, batch-planner.test.ts, batch-planner.md, index.ts}`.
|
||||
- [x] `src/store/{store.ts, store.test.ts, store.md, index.ts}`.
|
||||
- [x] `src/engine/{engine.ts, engine.test.ts, engine.md, index.ts}`.
|
||||
- [x] `src/workflow/{workflow.ts, workflow.test.ts, workflow.md, index.ts}`.
|
||||
- [x] `src/index.ts` root barrel with explicit named re-exports.
|
||||
|
||||
## §3 Verification gate
|
||||
|
||||
- [x] `bunx turbo lint typecheck test --filter=@suaveplan/campaigns` — green.
|
||||
- [x] `bunx turbo test:coverage --filter=@suaveplan/campaigns` — 100/100/100/100.
|
||||
- [x] `bunx turbo build --filter=@suaveplan/campaigns` — emits `dist/`.
|
||||
- [x] `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/communication/emails/campaigns/src/` — empty.
|
||||
- [x] `grep -rn "biome-ignore\|TODO\|FIXME\|XXX" packages/communication/emails/campaigns/src/` — empty.
|
||||
- [x] `bun scripts/lint/check-feature-layout.ts | grep campaigns` — no violations.
|
||||
- [x] `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`.
|
||||
|
||||
## §4 Commit
|
||||
|
||||
- [x] Single `feat(campaigns): ...` commit on the worktree branch. Never `--no-verify`, never auto-push.
|
||||
Reference in New Issue
Block a user