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"], anddefaults.basePath === "/v1/notifications"
Scenario: invalid options fail fast
- WHEN
createNotificationsPlugin({ basePath: "" } as never)is called - THEN the call throws
NotificationsPluginErrorwithcode === NOTIFICATIONS_PLUGIN_CODES.INVALID_CONFIG
Scenario: onInit registers repositories
- WHEN
onInit(ctx)runs with a realdbservice in the registry - THEN
ctx.getService("notificationRepository")andctx.getService("deviceTokenRepository")resolve to non-null instances
Scenario: onInit fails without database service
- WHEN
onInit(ctx)runs with nodbservice registered - THEN it throws
NotificationsPluginError(SERVICE_NOT_AVAILABLE)
Scenario: onInit registers APN when enabled
- WHEN
onInit(ctx)runs withenablePush === trueand a validapnconfig - THEN
ctx.getService("apnService")resolves to anApnServiceinstance whoseisInitialized()returnstrue
Scenario: onInit fails when enablePush without apn
- WHEN
onInit(ctx)runs withenablePush === trueandapn === undefined - THEN it throws
NotificationsPluginError(APN_CONFIG_MISSING)
Scenario: onInit registers notificationDispatcher when enableDispatcher is on
- WHEN
onInit(ctx)runs withenableDispatcher: true,enablePush: true, and a valid APN config - THEN
ctx.getService("notificationDispatcher")resolves to aNotificationDispatcherwhosedispatch({ channel: "apn", … })succeeds
Scenario: onInit refuses dispatcher with zero channels
- WHEN
onInit(ctx)runs withenableDispatcher: true,enablePush: false, and noemailServiceinjected - THEN it throws
NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)
Scenario: onReady mounts both route trees
- WHEN
onReady(ctx)runs afteronInit - THEN
ctx.getService("app").routescontains entries whosepathbegins with the configuredbasePathAND with the configuredpushBasePath
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 withenableDispatcher: true - THEN the dispatcher reference is nulled and subsequent
onReadyinvocations against a fresh app raiseSERVICE_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.idis 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…, andSTART $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
tokenon 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 nocert,key,pfx, ortoken - THEN the service throws
NotificationsPluginError(APN_CONFIG_MISSING)
Scenario: incomplete token raises APN_CONFIG_INVALID
- WHEN
initialize()runs withtoken: { key, keyId: "", teamId } - THEN the service throws
NotificationsPluginError(APN_CONFIG_INVALID)
Scenario: per-device failure surfaces in the result
- WHEN
sendNotificationis called against a transport whosefailurePlanmapsbad→{ 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
sendNotificationis called against a transport whosesendthrows - 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
401with 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'srecipient - THEN the response is
403withcode: NOTIFICATIONS_PLUGIN.FORBIDDEN
Scenario: delivery without APN returns 503
- WHEN
POST /push/sendis invoked withapnService === nullin the route config - THEN the response is
503withcode: NOTIFICATIONS_PLUGIN.APN_SERVICE_UNAVAILABLE
Scenario: successful create forwards event-bus event
- WHEN
POST /notificationssucceeds - THEN the injected
emitEventcallback receives{ type: "notification:created", payload: { notificationId, recipientId, type } }
Scenario: duplicate device token returns 409
- WHEN
POST /push/tokensis invoked with a token that triggersDEVICE_TOKEN_DUPLICATE - THEN the response is
409withcode: NOTIFICATIONS_PLUGIN.DEVICE_TOKEN_DUPLICATE
Scenario: bulk send caps recipients at maxBulkRecipients
- WHEN
POST /push/send-bulkis invoked withuserIdslonger thanmaxBulkRecipients - THEN the route processes only the first
maxBulkRecipientsentries;totalUsersreflects 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_CODESis inspected - THEN each value starts with
"NOTIFICATIONS_PLUGIN."
Scenario: every code reachable from src/
- WHEN the spec test concatenates every non-test
.tsundersrc/ - 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 | undefinedwithout 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 anddefaultChannel === "apn" - THEN the registered APN
DeliveryChannelreceivessend(notification)and the resultchannel === "apn"
Scenario: dispatch routes to an explicit channel when provided
- WHEN
dispatcher.dispatch({ recipient, body, channel: "email" })is called against a dispatcher with bothapnandemailchannels registered - THEN only the email channel receives
send, and the resultchannel === "email"
Scenario: unknown channel raises DISPATCH_CHANNEL_UNAVAILABLE
- WHEN
dispatcher.dispatch({ channel: "sms", … })is called and nosmschannel is registered - THEN the call rejects with
NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)
Scenario: channel-returned failure raises DISPATCH_FAILED
- WHEN the underlying channel's
sendreturns{ success: false, error: "transport failed" } - THEN
dispatchrejects withNotificationsPluginError(DISPATCH_FAILED)whosecontext.channelError === "transport failed"
Scenario: delivery-tracking sees a delivery:sent event on success
- WHEN
dispatchsucceeds and aDeliveryEventStoreis configured - THEN the store records a
DeliveryEventwitheventType === "sent", the dispatcher's tenantId, andmessageId === dispatchResult.id
Scenario: delivery-tracking sees a delivery:bounced event on failure
- WHEN
dispatchfails through a channel failure and aDeliveryEventStoreis configured - THEN the store records a
DeliveryEventwitheventType === "bounced"
Scenario: idempotency adapter dedupes repeated dispatches
- WHEN the dispatcher is configured with an adapter that caches by key and
dispatchis invoked twice with the same key - THEN the underlying channel's
sendis 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.sendNotificationis invoked withpayload.deviceToken === "tok"and the returnedDeliveryResult.success === true
Scenario: deviceTokens array fans out
- WHEN
apnChannel.send({ channel: "push", metadata: { deviceTokens: ["a","b"] }, body: "hi", … })is called - THEN
ApnService.sendBulkNotificationsis invoked with the two-element array and the returnedDeliveryResult.success === true
Scenario: missing deviceToken metadata
- WHEN
apnChannel.sendis called with nodeviceTokenand nodeviceTokens - 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.sendNotificationreturns{ sent: 0, failed: 1, errors: [{ error: "Unregistered" }] } - THEN
apnChannel.sendresolves withsuccess: falseanderror: "Unregistered"
Scenario: canSend gates on channel
- WHEN
apnChannel.canSend(notification)is called - THEN the result is
trueiffnotification.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.sendreceives{ 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
subjectand the channel was constructed withdefaultSubject: "Notification" - THEN
EmailService.sendreceivessubject: "Notification"
Scenario: HTML body via metadata
- WHEN
emailChannel.send({ channel: "email", metadata: { html: true }, body: "<p>x</p>", … })is called - THEN
EmailService.sendreceives{ html: "<p>x</p>" }instead oftext
Scenario: transport failure
- WHEN the underlying
EmailService.sendreturns{ 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 returnsfalse
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
keyis passed twice and the adapter caches by key - THEN the underlying
fnis 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, andNOTIFICATIONS_PLUGIN.EMAIL_SERVICE_UNAVAILABLE
Scenario: every dispatcher code reachable from src
- WHEN the spec-conformance scan concatenates every non-test
.tsundersrc/ - 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
notificationsDispatchedTotalandnotificationsDispatchFailedTotalproperties
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
onInitruns withenableDispatcher: true - THEN
ctx.getService("notificationDispatcher")resolves to aNotificationDispatcherinstance
Scenario: emailService slot populated when injected
- WHEN the host calls
createNotificationsPlugin({}, { emailService })andonInitruns - THEN
ctx.getService("emailService")resolves to the sameEmailServiceinstance