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

29 KiB

Capability Spec Delta: api-plugin-notifications (add-api-plugin-notifications)

Package: @suaveplan/api-plugin-notifications Status: PROPOSED

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

ADDED Requirements

Requirement: Plugin factory and lifecycle

The package SHALL export createNotificationsPlugin(options?: NotificationsPluginConfigInput, services?: NotificationsPluginServices) returning a PluginDescriptor<NotificationsPluginConfig> that mounts both the /notifications and /push route groups against @suaveplan/api. The factory SHALL Zod-validate input via NotificationsPluginOptionsSchema and throw NotificationsPluginError(INVALID_CONFIG) carrying the ZodError as cause on invalid input. onInit SHALL register notificationRepository, deviceTokenRepository, and (when enablePush) apnService in the host's ServiceRegistry. When the caller supplies services.emailService, onInit SHALL register it under the emailService slot. When the config has enableDispatcher: true, onInit SHALL construct a NotificationDispatcher wired with every available channel (apn if apnService is registered, email if emailService is registered) and register it under notificationDispatcher; the dispatcher SHALL refuse to construct when zero channels are available (NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)). onReady SHALL mount both route trees and refuse to double-mount. onClose SHALL shut down the APN transport (if any), null the dispatcher reference, and clear runtime state.

Verified by: src/plugin/plugin.test.ts, src/schemas/options.test.ts, src/api-plugin-notifications.spec-conformance.test.ts.

Scenario: valid options produce a descriptor

  • WHEN createNotificationsPlugin({ basePath: "/v1/notifications" }) is called
  • THEN the descriptor has name === "notifications", dependencies === ["database"], and defaults.basePath === "/v1/notifications"

Scenario: invalid options fail fast

  • WHEN createNotificationsPlugin({ basePath: "" } as never) is called
  • THEN the call throws NotificationsPluginError with code === NOTIFICATIONS_PLUGIN_CODES.INVALID_CONFIG

Scenario: onInit registers repositories

  • WHEN onInit(ctx) runs with a real db service in the registry
  • THEN ctx.getService("notificationRepository") and ctx.getService("deviceTokenRepository") resolve to non-null instances

Scenario: onInit fails without database service

  • WHEN onInit(ctx) runs with no db service registered
  • THEN it throws NotificationsPluginError(SERVICE_NOT_AVAILABLE)

Scenario: onInit registers APN when enabled

  • WHEN onInit(ctx) runs with enablePush === true and a valid apn config
  • THEN ctx.getService("apnService") resolves to an ApnService instance whose isInitialized() returns true

Scenario: onInit fails when enablePush without apn

  • WHEN onInit(ctx) runs with enablePush === true and apn === undefined
  • THEN it throws NotificationsPluginError(APN_CONFIG_MISSING)

Scenario: onInit registers notificationDispatcher when enableDispatcher is on

  • WHEN onInit(ctx) runs with enableDispatcher: true, enablePush: true, and a valid APN config
  • THEN ctx.getService("notificationDispatcher") resolves to a NotificationDispatcher whose dispatch({ channel: "apn", … }) succeeds

Scenario: onInit refuses dispatcher with zero channels

  • WHEN onInit(ctx) runs with enableDispatcher: true, enablePush: false, and no emailService injected
  • THEN it throws NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)

Scenario: onReady mounts both route trees

  • WHEN onReady(ctx) runs after onInit
  • THEN ctx.getService("app").routes contains entries whose path begins with the configured basePath AND with the configured pushBasePath

Scenario: onReady refuses to double-mount

  • WHEN onReady(ctx) is invoked twice against the same app instance
  • THEN the second call throws NotificationsPluginError(ROUTE_PREFIX_COLLISION)

Scenario: onClose clears repositories AND dispatcher

  • WHEN onClose() is invoked after a successful boot with enableDispatcher: true
  • THEN the dispatcher reference is nulled and subsequent onReady invocations against a fresh app raise SERVICE_NOT_AVAILABLE

Requirement: NotificationRepository contract

The package SHALL export a NotificationRepository class implementing INotificationRepository against the genesis SurrealAdapter. Every statement SHALL use positional placeholders ($p0, $p1, …) matching the DatabaseAdapter.query(sql, params: readonly unknown[]) surface. Twelve operations SHALL be present: create, findById, findByRecipient, getUnreadCount, update, markAsRead, markManyAsRead, markAllAsRead, dismiss, delete, findGroupedByType, deleteOldRead. Record-id normalisation SHALL accept both bare and prefixed ids; SurrealDB RecordId row shapes SHALL collapse to string ids inside transformRecord.

Verified by: src/repositories/notification-repository/notification-repository.test.ts.

Scenario: create returns transformed row

  • WHEN repo.create({ type, recipient, content }) returns a fresh notification id
  • THEN the returned Notification.id is a string, even when the adapter returned a { tb, id } RecordId shape

Scenario: empty adapter row raises NOTIFICATION_CREATE_FAILED

  • WHEN the adapter returns [] on CREATE
  • THEN the repository throws NotificationsPluginError(NOTIFICATION_CREATE_FAILED)

Scenario: findByRecipient builds the SurrealQL filter

  • WHEN findByRecipient(userId, siteId, opts) is called with every option
  • THEN the SurQL string contains WHERE recipient = type::thing('user', $p0), AND site = type::thing('site', $p…), AND read = false, AND type = $p…, AND created_at > $p…, LIMIT $p…, and START $p…

Scenario: deleteOldRead rejects negative input

  • WHEN deleteOldRead(-1) is invoked
  • THEN the repository throws NotificationsPluginError(INVALID_CONFIG)

Requirement: DeviceTokenRepository contract

The package SHALL export a DeviceTokenRepository class implementing IDeviceTokenRepository. Eight operations SHALL be present: register, findById, findByToken, findActiveForUser, deactivate, deactivateByToken, touchLastUsed, cleanup. Duplicate-token detection SHALL fold the SurrealDB UNIQUE-index violation into DEVICE_TOKEN_DUPLICATE. Other adapter failures SHALL fold into DEVICE_TOKEN_CREATE_FAILED.

Verified by: src/repositories/device-token-repository/device-token-repository.test.ts.

Scenario: duplicate token raises DEVICE_TOKEN_DUPLICATE

  • WHEN the adapter throws an error mentioning token on CREATE
  • THEN the repository throws NotificationsPluginError(DEVICE_TOKEN_DUPLICATE)

Scenario: generic create failure raises DEVICE_TOKEN_CREATE_FAILED

  • WHEN the adapter throws an unrelated error on CREATE
  • THEN the repository throws NotificationsPluginError(DEVICE_TOKEN_CREATE_FAILED)

Scenario: cleanup rejects negative daysOld

  • WHEN cleanup(-1) is invoked
  • THEN the repository throws NotificationsPluginError(INVALID_CONFIG)

Requirement: ApnService with pluggable transport

The package SHALL export an ApnService class that validates its config, builds an ApnTransport (default: createMemoryApnTransport), and exposes initialize / sendNotification / sendBulkNotifications / shutdown / isInitialized. Per-device failures SHALL appear inside the returned PushNotificationResult.errors array; the service SHALL NOT throw for per-device delivery failures. Transport-init errors SHALL fold into APN_TRANSPORT_INIT_FAILED; transport-shutdown errors fold into APN_TRANSPORT_SHUTDOWN_FAILED. The MemoryApnTransport ships as the deterministic default; hosts inject their own transport for production @parse/node-apn delivery.

Verified by: src/services/apn-service/apn-service.test.ts, src/transport/apn-transport/apn-transport.test.ts.

Scenario: missing auth material raises APN_CONFIG_MISSING

  • WHEN initialize() runs with no cert, key, pfx, or token
  • THEN the service throws NotificationsPluginError(APN_CONFIG_MISSING)

Scenario: incomplete token raises APN_CONFIG_INVALID

  • WHEN initialize() runs with token: { key, keyId: "", teamId }
  • THEN the service throws NotificationsPluginError(APN_CONFIG_INVALID)

Scenario: per-device failure surfaces in the result

  • WHEN sendNotification is called against a transport whose failurePlan maps bad → { status: 410, reason: "Unregistered" }
  • THEN the call resolves with { sent: 0, failed: 1, errors: [{ status: 410, reason: "Unregistered" }] } — no throw

Scenario: transport-thrown error folds into a synthetic failure

  • WHEN sendNotification is called against a transport whose send throws
  • THEN the call resolves with { sent: 0, failed: 1, errors: [{ error: <message> }] } — no throw

Scenario: bulk send empty list returns 0/0

  • WHEN sendBulkNotifications([], payload) is invoked
  • THEN the call resolves with { sent: 0, failed: 0 } without contacting the transport

Requirement: Hono OpenAPI route groups

The package SHALL export createNotificationsRoutes(config) and createPushRoutes(config) that return OpenAPIHono route bundles. Notifications routes SHALL cover 11 endpoints (list, unread-count, create, get, patch, mark-read, mark-many, mark-all, dismiss, delete, grouped-by-type). Push routes SHALL cover 5 endpoints (register token, unregister token, list tokens, send single, send bulk). Every route SHALL require auth via c.get("user") and return 401 { error, code: NOTIFICATIONS_PLUGIN.UNAUTHENTICATED } when missing. Mutating routes SHALL verify the notification belongs to the authenticated user; mismatches return 403 { code: FORBIDDEN }. Delivery endpoints SHALL return 503 when apnService === null (i.e. enablePush === false). Every successful mutation SHALL forward a NotificationsRouteEvent to the host's ctx.eventBus through the injected emitEvent callback.

Verified by: src/routes/notifications-routes/notifications-routes.test.ts, src/routes/push-routes/push-routes.test.ts.

Scenario: missing user returns 401

  • WHEN any route is hit without c.get("user") set
  • THEN the response is 401 with body { error: "Authentication required", code: "NOTIFICATIONS_PLUGIN.UNAUTHENTICATED" }

Scenario: ownership mismatch returns 403

  • WHEN PATCH /notifications/{id} is invoked by a user whose id does not match the notification's recipient
  • THEN the response is 403 with code: NOTIFICATIONS_PLUGIN.FORBIDDEN

Scenario: delivery without APN returns 503

  • WHEN POST /push/send is invoked with apnService === null in the route config
  • THEN the response is 503 with code: NOTIFICATIONS_PLUGIN.APN_SERVICE_UNAVAILABLE

Scenario: successful create forwards event-bus event

  • WHEN POST /notifications succeeds
  • THEN the injected emitEvent callback receives { type: "notification:created", payload: { notificationId, recipientId, type } }

Scenario: duplicate device token returns 409

  • WHEN POST /push/tokens is invoked with a token that triggers DEVICE_TOKEN_DUPLICATE
  • THEN the response is 409 with code: NOTIFICATIONS_PLUGIN.DEVICE_TOKEN_DUPLICATE

Scenario: bulk send caps recipients at maxBulkRecipients

  • WHEN POST /push/send-bulk is invoked with userIds longer than maxBulkRecipients
  • THEN the route processes only the first maxBulkRecipients entries; totalUsers reflects the cap

Requirement: Error-code registry

The package SHALL export NOTIFICATIONS_PLUGIN_CODES (frozen record built by defineCodes("NOTIFICATIONS_PLUGIN", [...])) and a NotificationsPluginError class extending SuaveplanError. Every code SHALL be reachable from at least one production source file; the spec-conformance test SHALL grep the source tree to assert reachability.

Verified by: src/error-codes/error-codes.test.ts.

Scenario: namespace literal is NOTIFICATIONS_PLUGIN

  • WHEN every value of NOTIFICATIONS_PLUGIN_CODES is inspected
  • THEN each value starts with "NOTIFICATIONS_PLUGIN."

Scenario: every code reachable from src/

  • WHEN the spec test concatenates every non-test .ts under src/
  • THEN the concatenation contains NOTIFICATIONS_PLUGIN_CODES.<KEY> for every key

Requirement: OpenTelemetry instruments

The package SHALL expose getNotificationsInstruments() / resetNotificationsInstruments() returning six counters (notificationsCreatedTotal, notificationsReadTotal, notificationsDeletedTotal, pushSentTotal, pushFailedTotal, deviceTokensRegisteredTotal). Each counter SHALL be emitted from at least one production code path and SHALL be observable through an InMemoryMetricExporter in the test suite.

Verified by: src/otel/instruments.test.ts.

Scenario: bundle is memoised

  • WHEN getNotificationsInstruments() is called twice without a reset
  • THEN both calls return the same object reference

Scenario: counters emit on flush

  • WHEN counters are incremented and flushTestMetrics() is called
  • THEN the exporter snapshot contains every counter name

Requirement: Service-registry augmentation

The package SHALL augment @suaveplan/api-plugin/service-registry's ServiceRegistry interface with three slots: notificationRepository (INotificationRepository), deviceTokenRepository (IDeviceTokenRepository), and apnService (ApnService). The package barrel SHALL side-effect import the augmentation so consumers that import any public symbol pick up the type widening automatically.

Verified by: src/api-plugin-notifications.spec-conformance.test.ts.

Scenario: registry slots typed

  • WHEN a downstream consumer calls ctx.getService("notificationRepository") against the augmented registry
  • THEN the result type narrows to INotificationRepository | undefined without a cast

Requirement: NotificationDispatcher orchestrator

The package SHALL export a NotificationDispatcher class that resolves @suaveplan/notification-channel's DeliveryChannel instances by the dispatcher-facing channel name and exposes a dispatch(input: DispatchInput): Promise<DispatchResult> surface. The dispatcher SHALL fan a single input out to one of the registered delivery channels (defaulting to the configured defaultChannel), record an OTel counter increment per dispatch (notificationsDispatchedTotal on success, notificationsDispatchFailedTotal on failure), and forward a delivery:sent or delivery:bounced event through the configured DeliveryEventStore (when one is supplied). Channel selection failures SHALL surface as NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE). Underlying transport failures returned by the channel as success === false SHALL surface as NotificationsPluginError(DISPATCH_FAILED) carrying the channel error as cause. Per-dispatch idempotency SHALL be applied through an injected DispatcherIdempotencyAdapter whose default implementation (NoopIdempotencyAdapter) is the identity.

Verified by: src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch routes to the default channel", src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch routes to an explicit channel", src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch raises DISPATCH_CHANNEL_UNAVAILABLE for an unknown channel", src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch raises DISPATCH_FAILED when the channel returns failed", src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch records delivery:sent through the configured store", src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch records delivery:bounced when the channel fails", src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch emits notificationsDispatchedTotal on success", src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch emits notificationsDispatchFailedTotal on failure".

Scenario: dispatch routes to the default channel when channel is unspecified

  • WHEN dispatcher.dispatch({ recipient, body }) is called and defaultChannel === "apn"
  • THEN the registered APN DeliveryChannel receives send(notification) and the result channel === "apn"

Scenario: dispatch routes to an explicit channel when provided

  • WHEN dispatcher.dispatch({ recipient, body, channel: "email" }) is called against a dispatcher with both apn and email channels registered
  • THEN only the email channel receives send, and the result channel === "email"

Scenario: unknown channel raises DISPATCH_CHANNEL_UNAVAILABLE

  • WHEN dispatcher.dispatch({ channel: "sms", … }) is called and no sms channel is registered
  • THEN the call rejects with NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)

Scenario: channel-returned failure raises DISPATCH_FAILED

  • WHEN the underlying channel's send returns { success: false, error: "transport failed" }
  • THEN dispatch rejects with NotificationsPluginError(DISPATCH_FAILED) whose context.channelError === "transport failed"

Scenario: delivery-tracking sees a delivery:sent event on success

  • WHEN dispatch succeeds and a DeliveryEventStore is configured
  • THEN the store records a DeliveryEvent with eventType === "sent", the dispatcher's tenantId, and messageId === dispatchResult.id

Scenario: delivery-tracking sees a delivery:bounced event on failure

  • WHEN dispatch fails through a channel failure and a DeliveryEventStore is configured
  • THEN the store records a DeliveryEvent with eventType === "bounced"

Scenario: idempotency adapter dedupes repeated dispatches

  • WHEN the dispatcher is configured with an adapter that caches by key and dispatch is invoked twice with the same key
  • THEN the underlying channel's send is called only once

Requirement: ApnDeliveryChannel adapter

The package SHALL export an ApnDeliveryChannel class implementing @suaveplan/notification-channel's DeliveryChannel interface against the in-package ApnService. The channel SHALL be registered under name === "apn" and SHALL accept the device token via Notification.metadata.deviceToken (string) or Notification.metadata.deviceTokens (string array, for fan-out). Missing-token inputs SHALL return { success: false, error: "deviceToken metadata required" }. APN per-device failures SHALL fold into a single DeliveryResult whose success reflects whether at least one delivery succeeded.

Verified by: src/channels/apn-channel/apn-channel.test.ts::"sends through ApnService for a single deviceToken", src/channels/apn-channel/apn-channel.test.ts::"fans out to multiple deviceTokens via metadata.deviceTokens", src/channels/apn-channel/apn-channel.test.ts::"returns success false when deviceToken metadata is missing", src/channels/apn-channel/apn-channel.test.ts::"folds APN per-device failures into the DeliveryResult", src/channels/apn-channel/apn-channel.test.ts::"reports canSend true only for push channel notifications".

Scenario: single deviceToken in metadata

  • WHEN apnChannel.send({ channel: "push", metadata: { deviceToken: "tok" }, body: "hi", … }) is called
  • THEN ApnService.sendNotification is invoked with payload.deviceToken === "tok" and the returned DeliveryResult.success === true

Scenario: deviceTokens array fans out

  • WHEN apnChannel.send({ channel: "push", metadata: { deviceTokens: ["a","b"] }, body: "hi", … }) is called
  • THEN ApnService.sendBulkNotifications is invoked with the two-element array and the returned DeliveryResult.success === true

Scenario: missing deviceToken metadata

  • WHEN apnChannel.send is called with no deviceToken and no deviceTokens
  • THEN the call resolves with { success: false, channel: "apn", error: "deviceToken metadata required" }

Scenario: APN per-device failure surfaces as DeliveryResult.error

  • WHEN the underlying ApnService.sendNotification returns { sent: 0, failed: 1, errors: [{ error: "Unregistered" }] }
  • THEN apnChannel.send resolves with success: false and error: "Unregistered"

Scenario: canSend gates on channel

  • WHEN apnChannel.canSend(notification) is called
  • THEN the result is true iff notification.channel === "push"

Requirement: EmailDeliveryChannel adapter

The package SHALL export an EmailDeliveryChannel class implementing @suaveplan/notification-channel's DeliveryChannel interface against @suaveplan/emailer's EmailService. The channel SHALL be registered under name === "email" and SHALL map Notification.recipient → EmailMessage.to, Notification.subject → EmailMessage.subject (defaulting to the configured defaultSubject when missing), Notification.body → EmailMessage.text (or html when metadata.html === true). Transport failures returned by EmailService.send SHALL surface as { success: false, error: <error message> }. The channel SHALL throw NotificationsPluginError(EMAIL_SERVICE_UNAVAILABLE) at construction when emailService is null.

Verified by: src/channels/email-channel/email-channel.test.ts::"sends through EmailService with subject + text", src/channels/email-channel/email-channel.test.ts::"falls back to defaultSubject when notification has no subject", src/channels/email-channel/email-channel.test.ts::"sends HTML body when metadata.html is true", src/channels/email-channel/email-channel.test.ts::"folds transport failure into DeliveryResult.error", src/channels/email-channel/email-channel.test.ts::"throws EMAIL_SERVICE_UNAVAILABLE when constructed with null service", src/channels/email-channel/email-channel.test.ts::"canSend gates on channel === email".

Scenario: success path with explicit subject

  • WHEN emailChannel.send({ channel: "email", recipient: "x@y.z", subject: "S", body: "B", … }) is called
  • THEN EmailService.send receives { to: "x@y.z", subject: "S", text: "B" } and the channel returns { success: true, channel: "email" }

Scenario: default subject fallback

  • WHEN the notification has no subject and the channel was constructed with defaultSubject: "Notification"
  • THEN EmailService.send receives subject: "Notification"

Scenario: HTML body via metadata

  • WHEN emailChannel.send({ channel: "email", metadata: { html: true }, body: "<p>x</p>", … }) is called
  • THEN EmailService.send receives { html: "<p>x</p>" } instead of text

Scenario: transport failure

  • WHEN the underlying EmailService.send returns { success: false, error: new Error("smtp dead"), … }
  • THEN the channel returns { success: false, error: "smtp dead" }

Scenario: null service refused at construction

  • WHEN new EmailDeliveryChannel({ emailService: null, … }) is invoked
  • THEN the constructor throws NotificationsPluginError(EMAIL_SERVICE_UNAVAILABLE)

Scenario: canSend gating

  • WHEN emailChannel.canSend({ channel: "email", … }) versus { channel: "push", … }
  • THEN the first returns true, the second returns false

Requirement: DispatcherIdempotencyAdapter contract

The package SHALL export a DispatcherIdempotencyAdapter interface exposing exactly one method execute<T>(key: string, fn: () => Promise<T>): Promise<T> and a default NoopIdempotencyAdapter class that simply calls fn(). Callers SHALL be able to plug @suaveplan/idempotency's IdempotencyGuard (whose execute(key, fn, opts?) signature is structurally compatible) without the package importing the guard at runtime. The dispatcher SHALL call adapter.execute(key, () => underlyingDispatch()) when a key is derivable from the input, short-circuiting duplicate dispatches with the adapter's cached return value.

Verified by: src/dispatcher/idempotency-adapter/idempotency-adapter.test.ts::"NoopIdempotencyAdapter calls fn exactly once", src/dispatcher/idempotency-adapter/idempotency-adapter.test.ts::"a caching adapter dedupes repeated executions", src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"idempotency adapter dedupes repeated dispatches".

Scenario: noop adapter is the identity

  • WHEN new NoopIdempotencyAdapter().execute("k", fn) is invoked
  • THEN fn() is called exactly once and its result is returned

Scenario: caching adapter dedupes

  • WHEN the same key is passed twice and the adapter caches by key
  • THEN the underlying fn is invoked only once and both calls return the same result

Requirement: Dispatch input/result schemas

The package SHALL export DispatchInputSchema and DispatchResultSchema (Zod) plus matching DispatchInput / DispatchResult types. DispatchInputSchema SHALL accept recipient (non-empty string), body (non-empty string), optional channel (one of the notification-channel channel literals), optional subject, optional metadata (record), optional priority, and an optional idempotencyKey. Invalid input SHALL produce NotificationsPluginError(DISPATCH_INPUT_INVALID) carrying the ZodError as cause.

Verified by: src/schemas/dispatch-schemas.test.ts::"DispatchInputSchema rejects empty recipient", src/schemas/dispatch-schemas.test.ts::"DispatchInputSchema rejects empty body", src/schemas/dispatch-schemas.test.ts::"DispatchInputSchema accepts a minimal payload", src/schemas/dispatch-schemas.test.ts::"DispatchInputSchema accepts idempotencyKey", src/schemas/dispatch-schemas.test.ts::"DispatchResultSchema accepts canonical shape".

Scenario: empty recipient rejected

  • WHEN DispatchInputSchema.safeParse({ recipient: "", body: "x" }) is invoked
  • THEN the result success === false

Scenario: minimal payload accepted

  • WHEN DispatchInputSchema.safeParse({ recipient: "u", body: "b" }) is invoked
  • THEN the result success === true

Requirement: Dispatcher error codes wired into production paths

The registry SHALL include DISPATCH_FAILED, DISPATCH_CHANNEL_UNAVAILABLE, DISPATCH_INPUT_INVALID, and EMAIL_SERVICE_UNAVAILABLE codes, and every one SHALL be thrown from at least one production source under src/. The error-codes.test.ts reachability scan SHALL include all four codes.

Verified by: src/error-codes/error-codes.test.ts::"every NOTIFICATIONS_PLUGIN_CODES value is referenced by src/".

Scenario: registry exposes the four dispatcher codes

  • WHEN Object.values(NOTIFICATIONS_PLUGIN_CODES) is inspected
  • THEN the array contains NOTIFICATIONS_PLUGIN.DISPATCH_FAILED, NOTIFICATIONS_PLUGIN.DISPATCH_CHANNEL_UNAVAILABLE, NOTIFICATIONS_PLUGIN.DISPATCH_INPUT_INVALID, and NOTIFICATIONS_PLUGIN.EMAIL_SERVICE_UNAVAILABLE

Scenario: every dispatcher code reachable from src

  • WHEN the spec-conformance scan concatenates every non-test .ts under src/
  • THEN the concatenation contains NOTIFICATIONS_PLUGIN_CODES.DISPATCH_FAILED, …DISPATCH_CHANNEL_UNAVAILABLE, …DISPATCH_INPUT_INVALID, and …EMAIL_SERVICE_UNAVAILABLE

Requirement: Dispatcher OTel counters

getNotificationsInstruments() SHALL expose two additional counters beyond the original six: notificationsDispatchedTotal (incremented by NotificationDispatcher on a successful dispatch, with channel attribute) and notificationsDispatchFailedTotal (incremented on a failed dispatch, with channel + reason attributes). Both SHALL be observable through an InMemoryMetricExporter in the OTel test.

Verified by: src/otel/instruments.test.ts::"exposes dispatcher counters", src/otel/instruments.test.ts::"dispatcher counters emit on flush".

Scenario: instrument bundle exposes the two new counters

  • WHEN getNotificationsInstruments() is called
  • THEN the returned object has notificationsDispatchedTotal and notificationsDispatchFailedTotal properties

Scenario: counters appear in the exporter snapshot

  • WHEN both counters are incremented and flushTestMetrics() is called
  • THEN the exporter snapshot includes both metric names

Requirement: Dispatcher service-registry slots

The package SHALL extend the existing ServiceRegistry module augmentation with two optional slots: notificationDispatcher (NotificationDispatcher) and emailService (EmailService from @suaveplan/emailer). The slots SHALL only be populated when the plugin's enableDispatcher option is true (dispatcher) or when the host injects an EmailService (email).

Verified by: src/api-plugin-notifications.spec-conformance.test.ts::"registry slots include dispatcher and emailService", src/plugin/plugin.test.ts::"onInit registers notificationDispatcher when enableDispatcher is on".

Scenario: dispatcher slot populated when enabled

  • WHEN onInit runs with enableDispatcher: true
  • THEN ctx.getService("notificationDispatcher") resolves to a NotificationDispatcher instance

Scenario: emailService slot populated when injected

  • WHEN the host calls createNotificationsPlugin({}, { emailService }) and onInit runs
  • THEN ctx.getService("emailService") resolves to the same EmailService instance