Skip to main content

baukit_push/
lib.rs

1//! Provider-neutral push delivery with an Expo adapter.
2//!
3//! The crate separates a small [`PushSender`] port from its [`ExpoPushSender`]
4//! adapter. Domain code builds [`PushMessage`] values and reads [`PushOutcome`]
5//! values; nothing above the port names a provider. Scheduling, quiet hours,
6//! and deciding who gets notified stay in the product.
7//!
8//! # Two-phase delivery
9//!
10//! Expo answers a send with a *ticket* per notification, then confirms delivery
11//! later through a *receipt*. [`ExpoPushSender`] runs both phases per batch, so
12//! one call returns [`PushDeliveryStatus::Delivered`] or
13//! [`PushDeliveryStatus::Rejected`] wherever Expo has settled, and
14//! [`PushDeliveryStatus::Accepted`] where it has not. Never resend on
15//! `Accepted`; the notification is in flight.
16//!
17//! # Deferred receipts
18//!
19//! An `Accepted` outcome carries the provider's [`PushTicketId`]. Record those
20//! with [`PendingReceiptStore::record_accepted`] and let the product's job
21//! runner call [`poll_pending_receipts`], which reads the receipts through the
22//! [`PushReceiptSource`] port and invalidates a `DeviceNotRegistered` token
23//! that Expo only reported after the send. The crate ships no scheduler.
24//!
25//! # Device registry
26//!
27//! A device token stops working once the app is uninstalled. Expo reports that
28//! as `DeviceNotRegistered`, which becomes
29//! [`PushRejection::DeviceNotRegistered`]. The [`DeviceRegistry`] port stores
30//! each owner's tokens and removes the dead ones in one call after a send:
31//!
32//! ```rust
33//! use baukit_push::{DeviceRegistry, PushMessage, PushSender};
34//! use chrono::Utc;
35//!
36//! async fn deliver(
37//!     sender: &impl PushSender,
38//!     registry: &impl DeviceRegistry,
39//!     messages: Vec<PushMessage>,
40//! ) -> Result<u64, Box<dyn std::error::Error>> {
41//!     let sent_at = Utc::now();
42//!     let outcomes = sender.send(messages).await?;
43//!     Ok(registry.invalidate_dead_tokens(&outcomes, sent_at).await?)
44//! }
45//! ```
46//!
47//! The registry also registers, rotates, unregisters, lists, and erases
48//! tokens, and caps devices per owner by evicting the least recently
49//! registered. The optional `sqlx-postgres` feature adds
50//! `PostgresDeviceRegistry` over the schema in
51//! [`POSTGRES_PUSH_DEVICES_MIGRATION_SQL`]. [`DeviceToken`] redacts itself in
52//! `Debug`, so [`PushMessage`] and [`PushOutcome`] are safe to log, and no
53//! error carries a token.
54//!
55//! # Daily delivery claims
56//!
57//! A scheduled sender claims a [`DeliveryClaim`] keyed by owner, local date,
58//! and kind before it sends, and releases it if the send fails, so two workers
59//! never deliver the same daily notification twice. The [`DeliveryClaimStore`]
60//! port is separate from the registry; event-driven senders skip it.
61//! Eligibility, copy, quiet hours, and channel choice stay in the product.
62//!
63//! # Retries
64//!
65//! Whole-request failures arrive as [`PushError::Transport`] carrying a
66//! [`baukit_http::RetryClass`], so an Expo rate limit with a `Retry-After`
67//! header reaches the caller as a concrete delay through
68//! [`PushError::retry_after`]. Per-notification refusals are not errors:
69//! inspect [`PushRejection::is_retryable`] on each outcome instead.
70
71#![deny(missing_docs)]
72
73mod config;
74mod delivery_claim;
75mod expo;
76#[cfg(feature = "test-support")]
77mod fake;
78#[cfg(feature = "test-support")]
79mod memory;
80mod pending_receipt;
81mod port;
82#[cfg(feature = "sqlx-postgres")]
83mod postgres;
84mod registry;
85
86pub use config::{
87    DEFAULT_EXPO_ENDPOINT, MAX_BATCH_SIZE, MAX_RECEIPT_BATCH_SIZE, PushConfig, PushOptions,
88    PushOptionsError,
89};
90pub use delivery_claim::{
91    DeliveryClaim, DeliveryClaimStore, DeliveryKind, MAX_DELIVERY_KIND_LENGTH,
92};
93pub use expo::ExpoPushSender;
94#[cfg(feature = "test-support")]
95pub use fake::FakePushSender;
96#[cfg(feature = "test-support")]
97pub use memory::{MemoryDeliveryClaimStore, MemoryDeviceRegistry, MemoryPendingReceiptStore};
98pub use pending_receipt::{
99    PendingReceipt, PendingReceiptStore, RECEIPT_POLL_DELAY, RECEIPT_RETENTION, ReceiptPoll,
100    ReceiptPollError, accepted_receipts, poll_pending_receipts,
101};
102pub use port::{
103    MAX_PUSH_TICKET_ID_LENGTH, PushDeliveryStatus, PushError, PushFuture, PushMessage, PushOutcome,
104    PushReceipt, PushReceiptFuture, PushReceiptSource, PushRejection, PushSender, PushTicketId,
105};
106#[cfg(feature = "sqlx-postgres")]
107pub use postgres::{
108    PostgresDeliveryClaimStore, PostgresDeviceRegistry, PostgresPendingReceiptStore,
109    erase_owner_delivery_claims, erase_owner_pending_receipts, erase_owner_push_devices,
110    purge_delivery_claims,
111};
112pub use registry::{
113    DEFAULT_DEVICES_PER_OWNER, DevicePlatform, DeviceRegistration, DeviceRegistry, DeviceTimeZone,
114    DeviceToken, MAX_DEVICE_TIME_ZONE_LENGTH, MAX_DEVICE_TOKEN_LENGTH, PushStoreError,
115    PushStoreFuture, PushValidationError, RegisteredDevice, RegistrationOutcome, dead_tokens,
116};
117
118/// Reference PostgreSQL schema for the `sqlx-postgres` feature's `PostgresDeviceRegistry`.
119///
120/// Copy this SQL into a product migration; do not execute it dynamically during
121/// application startup. Then add the product's owner foreign key with
122/// `ON DELETE CASCADE`, as the file's header describes.
123pub const POSTGRES_PUSH_DEVICES_MIGRATION_SQL: &str =
124    include_str!("../migrations/0001_baukit_push_devices.sql");
125
126/// Reference PostgreSQL schema for the `sqlx-postgres` feature's `PostgresDeliveryClaimStore`.
127///
128/// Only products that send scheduled pushes need it. Copy it into a product
129/// migration and add the owner foreign key the file's header describes.
130pub const POSTGRES_PUSH_DELIVERY_CLAIMS_MIGRATION_SQL: &str =
131    include_str!("../migrations/0002_baukit_push_delivery_claims.sql");
132
133/// Reference PostgreSQL schema for the `sqlx-postgres` feature's `PostgresPendingReceiptStore`.
134///
135/// Only products that poll receipts after the send need it. Copy it into a
136/// product migration, followed by
137/// [`POSTGRES_PUSH_PENDING_RECEIPT_OWNERS_MIGRATION_SQL`].
138pub const POSTGRES_PUSH_PENDING_RECEIPTS_MIGRATION_SQL: &str =
139    include_str!("../migrations/0003_baukit_push_pending_receipts.sql");
140
141/// Reference PostgreSQL migration that adds the owner to each pending ticket.
142///
143/// Apply it after [`POSTGRES_PUSH_PENDING_RECEIPTS_MIGRATION_SQL`] and add the
144/// owner foreign key the file's header describes, so erasing an owner removes
145/// the device tokens their pending tickets hold.
146pub const POSTGRES_PUSH_PENDING_RECEIPT_OWNERS_MIGRATION_SQL: &str =
147    include_str!("../migrations/0004_baukit_push_pending_receipt_owners.sql");
148
149// Compiles the README's examples so they cannot drift from the API.
150#[doc = include_str!("../README.md")]
151#[cfg(doctest)]
152struct ReadmeDoctests;