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

8.9 KiB

Capability Spec Delta: delivery-tracking (add-delivery-tracking)

Package: @suaveplan/delivery-tracking Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time. No exceptions.

ADDED Requirements

Requirement: Channel-agnostic event ingest with lifecycle dedupe

The package SHALL accept CreateDeliveryEventInput records, persist them via a pluggable DeliveryEventStore, and suppress duplicate opened/delivered/bounced/unsubscribed/complained events per messageId. Other event types MUST be allowed to repeat.

Scenario: First lifecycle event is recorded; the second is dropped

  • WHEN two opened events arrive for the same messageId
  • THEN the first call returns the persisted event and the second call returns null

Scenario: Non-lifecycle events are not deduped

  • WHEN two clicked events arrive for the same messageId
  • THEN both calls return persisted events

Verified by: src/core/tracker/tracker.test.ts::"dedupes lifecycle events per messageId" AND src/core/tracker/tracker.test.ts::"does not dedupe non-lifecycle events"


Requirement: Zod boundary validation on all inbound events

The package SHALL parse every CreateDeliveryEventInput with createDeliveryEventInputSchema before any I/O. Invalid input MUST raise DeliveryTrackingError(INVALID_INPUT).

Scenario: Empty tenantId is rejected

  • WHEN tracker.track({ ...input, tenantId: "" }) is called
  • THEN a DeliveryTrackingError whose code is DELIVERY_TRACKING.INVALID_INPUT is thrown

Verified by: src/core/tracker/tracker.test.ts::"throws DeliveryTrackingError(INVALID_INPUT) on schema violation"


Requirement: Typed EventBus emission

The package SHALL emit delivery:<eventType> topics on an EventBus<DeliveryTrackerEventMap> for every persisted event, allowing subscribers to react without polling the store.

Scenario: A persisted sent event triggers the delivery:sent topic

  • WHEN a sent event is tracked
  • THEN every listener subscribed to delivery:sent receives the persisted DeliveryEvent

Verified by: src/core/tracker/tracker.test.ts::"emits a typed bus event matching eventType"


Requirement: Injectable Clock and IdGenerator

The package SHALL accept clock and idGenerator options from @suaveplan/types so tests can pin timestamps and IDs deterministically.

Scenario: Pinned clock and id generator produce deterministic events

  • WHEN the tracker is constructed with clock: () => 1_700_000_000_000 and idGenerator: () => "id-N"
  • THEN the resulting event has timestamp.getTime() === 1_700_000_000_000 and id === "id-N"

Verified by: src/core/tracker/tracker.test.ts::"uses injected Clock + IdGenerator for deterministic ids"


Requirement: OpenTelemetry counter set

The package SHALL emit delivery_tracking.tracked.total, delivery_tracking.deduped.total, delivery_tracking.webhook_parsed.total, delivery_tracking.token_issued.total, and delivery_tracking.token_rejected.total counters and MUST allow tests to assert them via a real MeterProvider (no mocks).

Scenario: tracked.total reflects every persisted event

  • WHEN two events are tracked and one is deduped
  • THEN the delivery_tracking.tracked.total snapshot from the in-memory exporter is 2

Scenario: Instrument set is lazy + cached

  • WHEN getDeliveryTrackingInstruments() is called twice in a row
  • THEN both calls return the identical object reference

Verified by: src/core/tracker/tracker.test.ts::"emits tracked + deduped counters via real MeterProvider" AND src/otel/instruments.test.ts::"constructs the instrument set lazily and caches it"


Requirement: Backend-agnostic persistence contract

The package SHALL define DeliveryEventStore covering record + read + stats operations, ship an InMemoryDeliveryEventStore, and ship a SurrealDeliveryEventStore that uses positional SurQL via SurrealAdapter.query(sql, params).

Scenario: InMemory store supports filter + paging

  • WHEN events of multiple types are recorded and getByCampaign("c1", { eventType: "sent", channel: "sms", limit: 1, offset: 1 }) is called
  • THEN the returned slice respects all four filters

Scenario: SurrealDB store threads filters into the SQL

  • WHEN store.getByCampaign("c1", { eventType, channel, from, to, limit, offset }) is called
  • THEN the emitted SQL contains every filter predicate and LIMIT/START clauses

Verified by: src/stores/memory/memory-store.test.ts::"returns events for a campaign and supports filters + paging" AND src/stores/surreal/surreal-store.test.ts::"getByCampaign threads filters + paging into the SQL"


Requirement: Stable typed error codes on every IO path

The package SHALL raise DeliveryTrackingError with stable codes (INVALID_INPUT, INVALID_TOKEN, UNSUPPORTED_PROVIDER, STORE_FAILURE, QUERY_FAILURE) and MUST never leak driver-level exceptions across the public surface.

Scenario: SurrealAdapter write failure becomes STORE_FAILURE

  • WHEN SurrealAdapter.query throws inside record(event)
  • THEN the caller receives DeliveryTrackingError with code === DELIVERY_TRACKING.STORE_FAILURE

Scenario: SurrealAdapter read failure becomes QUERY_FAILURE

  • WHEN SurrealAdapter.query throws inside getByCampaign
  • THEN the caller receives DeliveryTrackingError with code === DELIVERY_TRACKING.QUERY_FAILURE

Verified by: src/stores/surreal/surreal-store.test.ts::"wraps adapter errors in DeliveryTrackingError(STORE_FAILURE)" AND src/stores/surreal/surreal-store.test.ts::"wraps adapter errors thrown during select in QUERY_FAILURE"


Requirement: Time-series aggregation

The package SHALL bucket campaign events by hour, day, or week. The result MUST be empty when the campaign has no events and MUST contain a computeStats-derived stats field per bucket otherwise.

Scenario: Hourly bucketing distributes events across buckets

  • WHEN three sent events at 10:00, 10:30, and 11:15 are recorded for c1 and getTimeSeriesStats("c1", "hour") is called
  • THEN the first bucket has stats.sent === 2 and the second has stats.sent === 1

Verified by: src/core/aggregator/aggregator.test.ts::"buckets events by hour"


The package SHALL provide TrackingPixelGenerator and TrackingLinkWrapper which encode (messageId, contactId, campaignId) (and originalUrl for clicks) as URL-safe opaque tokens. Decoding a malformed token MUST raise DeliveryTrackingError(INVALID_TOKEN).

Scenario: Pixel token round-trips

  • WHEN gen.generatePixelUrl(m, u, c) is called and the resulting token is parsed via gen.parsePixelToken(token)
  • THEN the parsed payload equals { messageId: m, contactId: u, campaignId: c }

Scenario: Malformed token raises INVALID_TOKEN

  • WHEN decodeToken("!!!not-base64!!!", "secret") is called
  • THEN a DeliveryTrackingError with code === DELIVERY_TRACKING.INVALID_TOKEN is thrown

Verified by: src/core/tracking-pixel/tracking-pixel.test.ts::"parses its own token back into the payload" AND src/core/tracking-pixel/token-codec.test.ts::"throws DeliveryTrackingError(INVALID_TOKEN) on malformed token"


The package SHALL provide wrapAllLinks(html, messageId, contactId, campaignId?) which rewrites every <a href="…"> in the input HTML with a click-tracking URL, preserving the surrounding attributes.

Scenario: Every anchor is rewritten

  • WHEN wrapAllLinks(htmlWithTwoAnchors, m, u) is called
  • THEN the output HTML contains two click-tracking URLs and the original hrefs are gone

Verified by: src/core/tracking-link/tracking-link.test.ts::"rewrites every <a href> in an HTML body"


Requirement: Pluggable provider webhook parsers

The package SHALL parse SendGrid, Mailgun, AWS SES, and a generic shape out of the box and MUST allow custom providers to be registered at construction time or at runtime. Unknown providers MUST raise DeliveryTrackingError(UNSUPPORTED_PROVIDER).

Scenario: A custom provider parser can be registered at runtime

  • WHEN parser.registerProvider("brevo", { parse: ... }) is called and parser.parse("brevo", payload) runs
  • THEN the custom parser's output is returned

Scenario: An unknown provider is rejected

  • WHEN parser.parse("mystery", { body: {} }) is called
  • THEN a DeliveryTrackingError with code === DELIVERY_TRACKING.UNSUPPORTED_PROVIDER is thrown

Verified by: src/core/webhook-parser/webhook-parser.test.ts::"supports registering a custom provider parser" AND src/core/webhook-parser/webhook-parser.test.ts::"raises UNSUPPORTED_PROVIDER for an unknown provider"