Date: 2026-05-24
Status: Draft
Library: libs/push/
Multi-platform push notification delivery library for the Boundary framework. Supports Firebase Cloud Messaging (FCM) and Apple Push Notification service (APNs) directly — no third-party abstraction services. Follows FC/IS architecture with defpush macro consistent with defreport, defevent, and defworkflow.
| Decision | Choice | Rationale |
|---|---|---|
| Device token storage | Self-contained in boundary-push | Same pattern as jobs (own store). No coupling to user module. Simple table, mechanical cleanup. |
| Provider strategy | FCM + APNs from day one | Two providers expose bad abstractions early. Covers full mobile ecosystem. |
defpush scope | Thick definitions | All config in definition (title, body, i18n, priority, TTL, deep-link, retry). Call sites stay clean. Matches other macros. |
| Provider protocols | Platform-specific behind unified service | IFCMProvider + IAPNsProvider instead of single IPushProvider. FCM and APNs have fundamentally different APIs/payloads. |
| Delivery analytics | Full with HMAC-secured callback endpoint | Server-side send tracking + client-reported delivery/open events via HMAC-signed callback. |
| Jobs integration | Hard dependency | All sends go through job queue. Push without retry/queue is fragile — no transport-level fallback like SMTP. |
| i18n | Built-in locale maps | Locale maps in defpush definition. No dependency on boundary-i18n. Push text is short and self-contained. |
libs/push/
├── src/boundary/push/
│ ├── core/
│ │ ├── notification.clj # defpush macro, registry, template rendering
│ │ ├── delivery.clj # Pure: build platform payloads, retry calc, fan-out logic
│ │ ├── device.clj # Pure: token validation, platform detection, staleness check
│ │ └── analytics.clj # Pure: aggregate stats, rate calculations
│ ├── ports.clj # IPushService, IFCMProvider, IAPNsProvider, IDeviceTokenStore, IPushAnalyticsStore
│ ├── schema.clj # Malli schemas for all domain types
│ └── shell/
│ ├── service.clj # IPushService impl — orchestrates providers, fan-out, analytics
│ ├── persistence.clj # IDeviceTokenStore + IPushAnalyticsStore impl (next.jdbc)
│ ├── adapters/
│ │ ├── mock.clj # MockFCMProvider + MockAPNsProvider (dev/test)
│ │ ├── fcm.clj # Google FCM v1 API (HTTP, OAuth2 service account)
│ │ └── apns.clj # Apple APNs (HTTP/2, JWT or certificate auth)
│ ├── handlers.clj # Ring handlers: device registration + analytics callback
│ ├── jobs.clj # Job handlers for async delivery + scheduled pushes
│ └── module_wiring.clj # Integrant init-key/halt-key!
├── test/boundary/push/
│ ├── core/
│ │ ├── notification_test.clj
│ │ ├── delivery_test.clj
│ │ └── device_test.clj
│ └── shell/
│ ├── persistence_test.clj # Contract tests (H2)
│ └── service_test.clj # Integration tests (mock adapters)
├── resources/migrations/
│ ├── 001-device-tokens.up.sql
│ ├── 001-device-tokens.down.sql
│ ├── 002-push-log.up.sql
│ ├── 002-push-log.down.sql
│ ├── 003-analytics-events.up.sql
│ └── 003-analytics-events.down.sql
├── deps.edn
├── build.clj
└── AGENTS.md
Dependencies:
boundary/jobs, boundary/coreboundary/devtools(defprotocol IPushService
(send-push! [this notification-id data opts]
"Enqueue push delivery for all user devices. opts: {:user-id uuid, :locale kw}")
(schedule-push! [this notification-id data opts scheduled-at]
"Schedule push for future delivery via jobs.")
(broadcast! [this notification-id data opts]
"Send to all registered devices matching opts: {:platform kw, :app-id str}"))
;; Note: send-to-device is an internal function in shell/service.clj,
;; not part of the public protocol. Job handlers call it directly.
(defprotocol IFCMProvider
(fcm-send! [this payload]
"Send FCM message. Returns {:success? bool :message-id str :error map}")
(fcm-send-multicast! [this payload tokens]
"Send to multiple FCM tokens. Returns per-token results.")
(fcm-validate-token [this token]
"Dry-run send to check token validity."))
(defprotocol IAPNsProvider
(apns-send! [this payload device-token]
"Send APNs notification. Returns {:success? bool :apns-id str :error map}")
(apns-send-batch! [this payload device-tokens]
"Send to multiple APNs devices. Returns per-token results."))
(defprotocol IDeviceTokenStore
(register-device! [this user-id device-info]
"Store device token. device-info: {:token str :platform kw :app-id str}")
(unregister-device! [this user-id device-token]
"Remove device token.")
(get-user-devices [this user-id]
"All active devices for user.")
(get-devices-by-platform [this platform opts]
"All devices for platform. opts: {:limit n :offset n}. Used by broadcast.")
(mark-token-invalid! [this device-token]
"Flag token as invalid after provider rejection.")
(cleanup-stale-tokens! [this max-age-days]
"Purge tokens not used within max-age-days."))
(defprotocol IPushAnalyticsStore
(record-send! [this event]
"Log send attempt with provider response.")
(record-delivery! [this event]
"Log client-reported delivery confirmation.")
(record-open! [this event]
"Log client-reported notification open.")
(get-push-stats [this notification-id opts]
"Aggregate stats: sent/delivered/opened/failed counts.")
(cleanup-old-events! [this retention-days]
"Purge analytics events older than retention-days. Recommended: 90 days."))
defpush Macro(defpush order-shipped
{:id :order-shipped
:title {:en "Order Shipped" :nl "Bestelling Verzonden"}
:body {:en "Your order {{order-id}} is on its way!"
:nl "Je bestelling {{order-id}} is onderweg!"}
:channels #{:fcm :apns}
:priority :high
:ttl 3600
:deep-link "/orders/{{order-id}}"
:silent? false
:collapse-key :order-status
:retry {:max-attempts 3 :backoff :exponential}})
defreport, defevent, defworkflow)valid-push? / explain-push functions for explicit validationget-push, list-pushes, clear-registry! for lookup and test isolationrender-template — interpolates {{var}} placeholders with data mapresolve-content — resolves localized content with fallback chain: requested locale -> :en -> first availablebuild-notification — combines locale resolution + template rendering into ready-to-send map(push/send-push! push-service :order-shipped
{:order-id "ORD-123" :eta "2 hours"}
{:user-id user-id :locale :nl})
| Schema | Purpose |
|---|---|
PushDefinition | Validates defpush definitions via valid-push? / explain-push |
DeviceInfo | Input for device registration (token, platform, app-id) |
DeviceRecord | Full device record with metadata and active flag |
SendPushInput | Input for send-push! (user-id, locale) |
AnalyticsEvent | Send/delivery/open event record |
PushStats | Aggregated stats output (counts + rates) |
CallbackPayload | Mobile app callback input (device-token, provider-message-id, event-type, callback-token) |
LocalizedString | Union type: plain string or locale->string map |
RetryConfig | Retry configuration (max-attempts, backoff strategy) |
| Column | Type | Notes |
|---|---|---|
| id | UUID | PK |
| user_id | UUID | NOT NULL |
| tenant_id | UUID | Optional, for multi-tenant contexts |
| token | VARCHAR(512) | NOT NULL |
| platform | VARCHAR(10) | 'fcm' or 'apns' |
| app_id | VARCHAR(255) | NOT NULL |
| device_name | VARCHAR(255) | Optional |
| os_version | VARCHAR(50) | Optional |
| active | BOOLEAN | Default TRUE, soft-deactivation |
| created_at | TIMESTAMP | |
| last_used_at | TIMESTAMP |
Unique constraint on (token, app_id). Indexes on (user_id, active) and (platform, active).
Multi-tenancy: tenant_id columns are optional across all tables. Tenant scoping is handled at middleware/context layer (same pattern as jobs module shell/tenant_context.clj), not baked into protocol method signatures.
| Column | Type | Notes |
|---|---|---|
| id | UUID | PK |
| notification_id | VARCHAR(255) | defpush :id |
| user_id | UUID | Optional |
| device_token_id | UUID | FK to push_device_tokens.id |
| device_token | VARCHAR(512) | Raw token for audit (survives token cleanup) |
| platform | VARCHAR(10) | |
| title | VARCHAR(500) | Rendered title |
| body | TEXT | Rendered body |
| priority | VARCHAR(10) | Default 'normal' |
| status | VARCHAR(20) | queued/sent/failed |
| provider_message_id | VARCHAR(255) | From FCM/APNs response |
| error_message | TEXT | On failure |
| created_at | TIMESTAMP | |
| sent_at | TIMESTAMP | |
| tenant_id | UUID | Optional, for multi-tenant contexts |
Write-once table: status reflects send outcome only. Post-send states (delivered/opened) live in push_analytics_events.
Indexes on (notification_id, created_at) and (user_id, created_at).
| Column | Type | Notes |
|---|---|---|
| id | UUID | PK |
| notification_id | VARCHAR(255) | |
| device_token | VARCHAR(512) | |
| platform | VARCHAR(10) | |
| event_type | VARCHAR(20) | sent/delivered/opened/failed |
| user_id | UUID | Optional |
| provider_message_id | VARCHAR(255) | |
| error_message | TEXT | |
| timestamp | TIMESTAMP | |
| tenant_id | UUID | Optional, for multi-tenant contexts |
Indexes on (notification_id, event_type) and (timestamp).
Retention policy: cleanup-old-events! purges events older than configurable retention period (recommended: 90 days). Run as scheduled job via boundary-jobs.
send-push! → enqueue :push/send job → job worker picks up
→ resolve defpush definition from registry
→ fetch user's active devices from store
→ group devices by platform
→ for each platform group:
→ build platform-specific payload (pure, in core/delivery.clj)
→ call IFCMProvider or IAPNsProvider
→ record-send! analytics event per device
→ mark-token-invalid! for rejected tokens
broadcast! uses paginated device fetch to avoid OOM on large device setsschedule-push! is a delayed job — jobs module handles schedulingretry-delay-ms calculation in corebuild-fcm-payload — transforms rendered notification into FCM v1 API structure (token, notification, data, android config)build-apns-payload — transforms into APNs structure (aps alert, sound, badge, content-available, mutable-content)Pure function classify-error maps provider error codes to action categories:
| Category | FCM errors | APNs errors | Action |
|---|---|---|---|
:retryable | UNAVAILABLE, INTERNAL | ServiceUnavailable | Re-enqueue job with backoff |
:token-invalid | UNREGISTERED, INVALID_ARGUMENT | BadDeviceToken, Unregistered | mark-token-invalid!, don't retry |
:rate-limited | QUOTA_EXCEEDED | TooManyRequests | Re-enqueue with longer backoff |
:permanent | PERMISSION_DENIED, SENDER_ID_MISMATCH | BadCertificate, Forbidden | Log error, don't retry |
Job handler consults classify-error before deciding to re-enqueue or give up.
Provider returns :token-invalid classified error → mark-token-invalid! sets active = false → future sends skip that token. cleanup-stale-tokens! purges old inactive tokens periodically.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /api/push/devices | User | Register device token |
| GET | /api/push/devices | User | List user's devices |
| DELETE | /api/push/devices/:token | User | Unregister device |
| POST | /api/push/callback | HMAC | Mobile app delivery/open callback |
| GET | /api/push/stats/:notification-id | Admin | Delivery/open rate stats |
Callback endpoint is secured with HMAC-signed tokens:
HMAC-SHA256(server-secret, provider-message-id)data field as callback-tokencallback-token back with callback POSTThis prevents fabricated delivery/open events without requiring user authentication. Duplicate callbacks are idempotent (upsert by provider-message-id + event-type). Rate limiting recommended at middleware level.
:boundary.push/fcm-provider {:provider :mock}
:boundary.push/apns-provider {:provider :mock}
:boundary.push/device-store {:db #ig/ref :boundary/datasource}
:boundary.push/analytics-store {:db #ig/ref :boundary/datasource}
:boundary.push/service
{:device-store #ig/ref :boundary.push/device-store
:analytics-store #ig/ref :boundary.push/analytics-store
:fcm-provider #ig/ref :boundary.push/fcm-provider
:apns-provider #ig/ref :boundary.push/apns-provider
:job-queue #ig/ref :boundary.jobs/queue
:callback-secret #env PUSH_CALLBACK_SECRET}
:boundary.push/job-handlers
{:push-service #ig/ref :boundary.push/service
:job-registry #ig/ref :boundary.jobs/registry}
:boundary.push/routes
{:device-store #ig/ref :boundary.push/device-store
:analytics-store #ig/ref :boundary.push/analytics-store
:callback-secret #env PUSH_CALLBACK_SECRET}
:boundary.push/fcm-provider
{:provider :fcm
:project-id #env FIREBASE_PROJECT_ID
:credentials-path #env GOOGLE_APPLICATION_CREDENTIALS}
:boundary.push/apns-provider
{:provider :apns
:team-id #env APNS_TEAM_ID
:key-id #env APNS_KEY_ID
:key-path #env APNS_KEY_PATH
:bundle-id #env APNS_BUNDLE_ID
:sandbox? false}
| Layer | Metadata | What | How |
|---|---|---|---|
| Unit | ^:unit | Template rendering, payload building, retry calc, locale fallback, schema validation | Pure function tests, no mocks |
| Integration | ^:integration | Full send flow, job handler execution, Ring handlers | Mock providers + in-memory stores |
| Contract | ^:contract | Device CRUD, analytics queries, token lifecycle | next.jdbc against H2 |
clojure -M:test:db/h2 :push # All push tests
clojure -M:test:db/h2 :push --focus-meta :unit # Unit only
clojure -M:test:db/h2 :push --focus-meta :contract # Contract only
clojure -M:test:db/h2 --focus boundary.push.core.notification-test # Single ns
Test isolation: clear-registry! in fixtures between defpush tests.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |