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

View File

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

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