14 KiB
add-api-plugin-notifications
Package: @suaveplan/api-plugin-notifications
Status: PROPOSED
Type: new-package
Design: (none — straightforward migration; see this proposal + spec delta)
Spec delta: ./specs/api-plugin-notifications/spec.md
Why
Genesis ships the foundational pieces of @suaveplan/api (database,
search, events, auth, site, etc.) but is missing the
notifications plugin — in-app notification CRUD, device-token
registration, and APN push delivery. Without this plugin, downstream
apps that need lock-screen pushes or in-app notification feeds have
to hand-roll the surface. Upstream
enterprise/api-plugin-notifications.md documents the audit (4
*.test.ts files, two .spec.ts Playwright integration files, the
APN mock smell, the mock middleware no-op, and the
autoCleanOldDays abandoned config flag).
This change brings the package across at full functional parity,
adapted to the genesis @suaveplan/api plugin shape
(PluginDescriptor, ctx.getService(...)), the genesis
DatabaseAdapter positional-param surface, and the
ctx.eventBus cross-plugin emission pattern. The local-emitter
wart and the APN mock are both replaced — the APN service now
shapes payloads against a pluggable transport interface
(ApnTransport) so the plugin never depends on a concrete APN
client library. Production hosts inject a node-apn adapter; the
plugin ships a deterministic MemoryApnTransport that records every
send for hermetic tests.
What changes
- ADDED:
createNotificationsPlugin(options?)— factory returning aPluginDescriptor<NotificationsPluginConfig>for@suaveplan/api. - ADDED:
NotificationsPluginOptionsSchema/NotificationsPluginConfig/NotificationsPluginConfigInput— Zod-validated config (basePath, pushBasePath, enablePush, apn, autoCleanOldDays, maxBulkRecipients). - ADDED:
ApnConfigSchema/ApnConfig— Zod validation of the APN config bundle (cert/key/pfx/token + topic + production + network knobs). - ADDED:
NotificationRepository+INotificationRepository— SurrealDB-backed notification CRUD repository over the genesisSurrealAdapterservice slot. - ADDED:
DeviceTokenRepository+IDeviceTokenRepository— SurrealDB-backed device-token CRUD repository. - ADDED:
createNotificationsRoutes— Hono OpenAPI route module with 11 endpoints (list / unread-count / create / get / patch / read / read-many / read-all / dismiss / delete / grouped collapses into list). - ADDED:
createPushRoutes— Hono OpenAPI route module with 5 endpoints (register / unregister / list tokens / send / send-bulk). - ADDED:
ApnService+createApnService— APN delivery service with a pluggable transport. - ADDED:
MemoryApnTransport+createMemoryApnTransport+ApnTransport/ApnTransportNotification/ApnTransportResponse/ApnTransportFactory— the transport contract and a deterministic default implementation. - ADDED:
NOTIFICATIONS_PLUGIN_CODESregistry +NotificationsPluginErrorclass — 18 stable codes spanning configuration, lifecycle, repository, device-token, route, and APN failure modes. - ADDED: OTel instruments — 6 counters
(
notificationsCreatedTotal,notificationsReadTotal,notificationsDeletedTotal,pushSentTotal{platform},pushFailedTotal{reason},deviceTokensRegisteredTotal{platform}). - ADDED: ServiceRegistry augmentation —
notificationRepository,deviceTokenRepository,apnServiceslots become available to downstream plugins via typedctx.getService(...). - ADDED: Local Zod schemas for notification and push surfaces
(
NotificationSchema,CreateNotificationSchema,UpdateNotificationSchema,NotificationQuerySchema,NotificationListResponseSchema,MarkManyAsReadSchema,DeviceTokenSchema,RegisterDeviceTokenSchema,SendPushNotificationSchema,SendBulkPushNotificationSchema, etc.) —@suaveplan/api-schemason genesis does not yet ship anotificationsubpath, so the plugin owns the boundary schemas. - ADDED:
surql/tables/notification.surql+surql/tables/device_token.surql— slimmed migrations covering only the columns the repositories read/write. The upstream cross-plugin permission rules, stored helper functions (fn::notification::*,fn::device::token::*,fn::push::queue::*,fn::push::history::*), bitwise-typed notification-type constants, andpush_notification_queue/push_notification_historytables are excluded (see below). - ADDED:
NotificationsPluginEventsevent-map keys —notifications:initialized,notifications:ready,notification:created,notification:read,notification:dismissed,notification:deleted,notification:push:sent,notification:push:failed,notification:device:registered,notification:device:unregistered.
Excluded from this migration
These items from enterprise/api-plugin-notifications.md
"Required enterprise-quality improvements" and "Feature-rich
enhancements" are deferred because each requires cross-package deps
that don't yet exist on genesis (the same scope-out pattern that
add-api-plugin-events used for fn::notification, fn::bitwise,
fn::group references):
- Real
@parse/node-apnintegration in the production codepath — the catalog deliberately omits@parse/node-apn. The migration ships anApnTransportinjection contract instead; production hosts add the library to their application dependencies and inject anode-apn-backed transport. The deterministicMemoryApnTransportships with the plugin and is the default in tests + dev. - FCM / Web Push / Email / SMS / Slack channels — depend on
@suaveplan/notification-channel,@suaveplan/emailer,@suaveplan/sms-providers,@suaveplan/slack-clientwhich are not yet on genesis. Deferred toadd-api-plugin-notifications-channels. autoCleanOldDaysscheduled cleanup —deleteOldRead(daysOld)is shipped on the repository and is callable; wiring it through@suaveplan/job-schedulerononReadywaits for the job-scheduler plugin (concurrent migration). Deferred toadd-api-plugin-notifications-cleanup-job.- Preference center, quiet hours, templates, suppression lists,
delivery tracking, batching+backoff queue, rich media, digests,
in-app SSE/WS — every one of these requires a sibling plugin that
doesn't exist on genesis yet (
@suaveplan/templates,@suaveplan/suppression,@suaveplan/delivery-tracking,@suaveplan/queue,@suaveplan/api-plugin-sse,@suaveplan/websocket-gateway,@suaveplan/datetime,@suaveplan/i18n). Each lands in its own follow-up change. - Auth gating via
@suaveplan/api-plugin-auth— this plugin readsc.get("user")(set by the host's auth middleware) and raisesUNAUTHENTICATEDwhen missing; explicitrequireAuth()composition is a host-level concern. - Audit-log emission, idempotency, rate-limit — depend on
@suaveplan/api-plugin-audit,@suaveplan/idempotency,@suaveplan/api-plugin-rate-limit(concurrent migrations); will be composed at the host level once they land. - Upstream
push_notification_queue/push_notification_historytables,fn::push::queue::*,fn::push::history::*,fn::notification::create/should_create/friend_request/post_like/commentSurrealQL helpers — all referencerecord<site>/record<post>/record<comment>/record<group>types and callfn::bitwise::has_bits/fn::settings::get/fn::session::is_rootwhich only exist inside sibling plugins (api-plugin-site,api-plugin-bitwise,api-plugin-groups). To avoid hand-rolling four downstream plugins as a side-quest, the migration ships a slimmer schema covering only the columns the repositories read / write; queue + history + helper functions migrate when their dependent plugins land.
Source mapping
This change derives from
../suaveplan/enterprise/api-plugin-notifications.md and the upstream
source at ../suaveplan/packages/server/api-plugin-notifications/.
| Requirement | Source lines | Notes |
|---|---|---|
Plugin factory createNotificationsPlugin(config?) |
enterprise.md L17-L29; src/index.ts L64-L130 | reworded — genesis uses PluginDescriptor not Plugin<NotificationConfig> |
| Config schema (basePath, pushBasePath, enablePush, apn, autoCleanOldDays) | enterprise.md L13, L27-L56; src/index.ts L27-L56 | extended with pushBasePath and maxBulkRecipients for genesis route-mount + bulk cap |
Plugin dependencies: ["database"] |
enterprise.md L18; src/index.ts L75 | verbatim |
migrationsPath removed in genesis |
enterprise.md L86; src/index.ts L86 | the database plugin owns migration application in genesis |
| NotificationRepository: create/findById/findByRecipient/getUnreadCount/update/markAsRead/markManyAsRead/markAllAsRead/dismiss/delete/findGroupedByType/deleteOldRead | src/repository/notification.repository/notification.repository.ts | reworded — typed db.create/select/update/delete replaced by positional SurQL (api-plugin-events Path-A pattern); upstream getById → findById, getByRecipient → findByRecipient, getGroupedByType → findGroupedByType, deleteOldNotifications → deleteOldRead |
| DeviceTokenRepository: register/findById/findByToken/findActiveForUser/deactivate/deactivateByToken/touchLastUsed/cleanup | upstream surql/tables/device_token.surql helpers | new — upstream put device-token CRUD in stored functions; genesis pulls them up into a TS repository so the plugin can run without the surql helpers |
| createNotificationsRoutes (11 endpoints) | src/routes/notifications/notifications.ts | verbatim — ctx.eventBus replaces local EventEmitter; grouped collapses into list filter |
| createPushRoutes (5 endpoints) | src/routes/push/push.ts | reworded — device_registration table folded into device_token; bulk send capped by maxBulkRecipients schema bound |
| APNService (initialize/sendNotification/sendBulkNotifications/shutdown/isInitialized) | src/services/apn/apn.service.ts | reworded — apn.Provider swapped for an injectable ApnTransport; same payload shape, same validateConfig rules, same mapResponse semantics |
| MemoryApnTransport (deterministic in-memory delivery) | n/a (genesis-original) | required because the genesis catalog omits @parse/node-apn |
notification SurrealQL table |
upstream surql/tables/notification.surql | slimmed — see ## Excluded from this migration |
device_token SurrealQL table |
upstream surql/tables/device_token.surql | slimmed |
Requirements with no source line
| Requirement | Justification |
|---|---|
| 100% test / branch / function / line coverage | Genesis-wide policy (CLAUDE.md "Quality", AGENTS.md §7). |
defineCodes("NOTIFICATIONS_PLUGIN", [...]) namespace |
Genesis convention; avoids collision with EVENTS (@suaveplan/events) and EVENTS_PLUGIN (@suaveplan/api-plugin-events). |
OTel instruments via metrics.getMeter("@suaveplan/api-plugin-notifications") |
Genesis-wide observability policy; emitted from production paths and asserted with a real in-memory MeterProvider (AGENTS.md §13). |
Use ctx.eventBus from @suaveplan/api-plugin context, not a local EventEmitter |
Fixes the upstream smell where notifications fired into a private EventEmitter invisible to siblings. |
| Subpath exports per feature module | AGENTS.md §6 — required so consumers can tree-shake. |
| Schemas owned locally | @suaveplan/api-schemas on genesis lacks a notification subpath; the plugin's schemas live inside src/schemas/ until upstream is augmented. |
Positional $p0-style params instead of named $id |
Genesis DatabaseAdapter.query(sql, params: readonly unknown[]) wraps params as {p0: …, p1: …}. Path-A escape valve documented in CLAUDE.md. |
Injectable ApnTransport |
Genesis catalog omits @parse/node-apn (network-touching libraries are injected by the host). Matches the genesis convention for transport adapters and means the plugin's test suite is hermetic. |
Verification approach
src/error-codes/error-codes.test.ts— pins every code is reachable from a real call site.src/otel/instruments.test.ts— realMeterProvider, asserts instrument shape + emission.src/schemas/notification-schemas.test.ts+src/schemas/push-schemas.test.ts+src/schemas/options.test.ts— Zod boundary tests.src/repositories/notification-repository/notification-repository.test.ts— full repository surface against a stubSurrealAdapter.src/repositories/device-token-repository/device-token-repository.test.ts— same coverage shape for device-tokens.src/transport/apn-transport/apn-transport.test.ts—MemoryApnTransportbehaviour including failure plans.src/services/apn-service/apn-service.test.ts— full APN service surface, every error branch, transport-injection and factory pathways.src/routes/notifications-routes/notifications-routes.test.ts— every route, every status code, ownership check, auth guard, event-bus emission, OTel emission.src/routes/push-routes/push-routes.test.ts— register / unregister / list / send / send-bulk against an in-memory APN service.src/plugin/plugin.test.ts— lifecycle: onInit registers repos + APN, double-mount guard, missing-dep failure, emits initialized/ready.src/api-plugin-notifications.spec-conformance.test.ts— pins every public symbol re-exported by the package root.tests/integration/full-boot.test.ts— real SurrealDB testcontainer, applies the two surql migrations, then full plugin boot + CRUD roundtrip on both repositories.
Migration impact
None — additive only. This is a brand-new package on genesis.
Consumers will opt-in via createNotificationsPlugin() once they
need notification routes.
Impact
Affected dependents
None on genesis today — package is additive. Downstream apps that previously had no notifications capability gain access.