unified-notification-core
v0.1.7
Published
UNC is a Drizzle-native multichannel notification core with durable delivery, retries, in-app inboxes, and recipient preferences.
Maintainers
Readme
UNC — Unified Notification Core
A transport-neutral notification core for TypeScript applications using Drizzle and PostgreSQL.
It is a package, not a microservice. Your application keeps its database connection, HTTP framework, user model, provider SDKs, and worker lifecycle. UNC adds durable notification records, per-channel delivery state, retries, scheduling, idempotency, recipient preferences, and an in-app inbox.
Why this boundary
Email, SMS, and Push implementations are application-specific. One application may send mail through Nodemailer and Postfix, another through Resend; one may send SMS through an Android gateway, another through Twilio. This package does not choose.
Instead, register callbacks:
const notificationCore = createNotificationCore<AppChannel>({
db,
adapters: {
email: async ({ recipientId, payload }) => {
const recipient = await users.findContact(recipientId);
const result = await sendMail({
to: recipient.email,
subject: payload.title,
text: payload.body,
});
return { providerMessageId: result.messageId };
},
sms: async ({ recipientId, payload }) => {
const recipient = await users.findContact(recipientId);
const result = await sendSMS({
to: recipient.phone,
text: payload.body,
});
return { providerMessageId: result.id };
},
push: async ({ recipientId, payload }) => {
const result = await sendPush(recipientId, payload);
return { metadata: { sentDevices: result.sent } };
},
},
defaultChannels: ["in_app", "push"],
});The adapter resolves addresses/subscriptions at delivery time. Email addresses, phone numbers, Push subscriptions, provider credentials, and provider-specific retry queues stay where they belong: in the application.
Install
npm install unified-notification-core drizzle-ormThe package supports Drizzle 0.45.x with PostgreSQL-compatible drivers,
including node-postgres, postgres.js, and PGlite.
Both public entrypoints support ESM import and CommonJS require:
unified-notification-core and unified-notification-core/schema.
Add the schema
Export the UNC tables from the schema entrypoint already read by Drizzle Kit:
// src/db/notification-schema.ts
import { createNotificationSchema } from "unified-notification-core/schema";
export const notificationSchema = createNotificationSchema();
export const {
notifications,
notificationDeliveries,
notificationPreferences,
notificationDispatchState,
} = notificationSchema;Pass the same schema object to UNC:
import { createNotificationCore } from "unified-notification-core";
import { notificationSchema } from "./db/notification-schema.js";
const notificationCore = createNotificationCore<AppChannel>({
db,
schema: notificationSchema,
adapters,
});The default table prefix is unc_. A different prefix can be selected once,
when defining the application schema:
createNotificationSchema({ tablePrefix: "app_notification_" });Run the application's normal Drizzle schema workflow after exporting the tables. The package never connects to or mutates a database by itself.
Upgrading an existing 0.1.0 database to 0.1.1 adds a functional recipient
preference index used by legacy-key fallback reads. Generate and apply that
schema diff through the application's normal reviewed migration workflow. UNC
does not create the index at runtime; correctness is preserved before the index
is applied, but legacy fallback reads can be slower.
Upgrading to 0.1.5 adds the unc_dispatch_state table, or the same suffix
under a custom prefix. That table stores provider-agnostic channel rate-limit
windows and cooldowns. Generate and apply the schema diff through the
application's normal reviewed migration workflow before enabling
channelPolicies or adapter retryAfterMs hints. UNC never creates the table
at runtime.
The fallback delivery feature adds fallback_enabled boolean not null default
false to unc_notifications. Generate and apply that schema diff through the
application's normal reviewed migration workflow before using
fallbackDelaysMs.
Define channels
Channel names are plain text, not a PostgreSQL enum. Adding a future channel does not require a core release or enum migration:
type AppChannel =
| "in_app"
| "email"
| "sms"
| "push"
| "whatsapp"
| "slack";in_app is built in. Every other channel must have an adapter, which makes a
missing provider integration fail at application startup instead of silently
dropping messages.
Publish and deliver
const result = await notificationCore.publishAndDispatch({
recipientIds: [userId],
topic: "call.incoming",
channels: ["in_app", "push"],
idempotencyKey: `call:${callId}:incoming`,
entityType: "call",
entityId: callId,
payload: {
title: "Incoming call",
body: "A customer is waiting.",
actionUrl: `/calls/${callId}`,
data: { callId },
channelData: {
push: { tag: `call-${callId}`, ttl: 60 },
},
},
});For gradual delivery, keep channels in priority order and provide a strictly
increasing delay for every selected channel. Delays are offsets from
scheduledFor (or the publication time when it is omitted):
await notificationCore.publishAndDispatch({
recipientIds: [userId],
topic: "appointment.reminder",
channels: ["in_app", "push", "email"],
fallbackDelaysMs: {
in_app: 0,
push: 5 * 60_000,
email: 30 * 60_000,
},
payload,
});UNC attempts only due channels. Once the recipient calls markRead(), the
remaining pending or retrying deliveries in that fallback publication become
skipped; an already-processing provider call cannot be recalled. Omitting
fallbackDelaysMs preserves ordinary fan-out behavior.
publish() only persists. publishAndDispatch() persists and immediately
attempts every due delivery from that publication. Internally it drains large
publications in bounded delivery-id claim pages, so broadcasts above 1,000
deliveries are not left pending just because a single claim wave is capped. Both
methods use the same durable outbox records.
Publication validates the complete JSON payload before opening its transaction.
data, channel-specific values, and other persisted payload properties must be
recursive JSON values: cycles, undefined, functions, symbols, bigints,
non-finite numbers, sparse arrays, accessors, and non-plain objects are rejected
with NotificationConfigurationError. TypeScript's JsonValue cannot express
the finite-number restriction, so this is also enforced at runtime. Invalid
publish input creates no notification or delivery rows.
Every persisted public string and JSON key/value must also be PostgreSQL-safe UTF-8 text: NUL and unpaired UTF-16 surrogates are rejected, while valid surrogate pairs such as emoji remain supported. Optional actor/entity ids must be non-empty strings when present. Runtime booleans are exact booleans; truthy or falsy substitutes are not accepted.
Configuration objects, public method option/input wrappers, and channel-keyed
maps must be plain or null-prototype records. Maps, Sets, Dates, arrays,
functions, class instances, symbols, and accessor properties are rejected at
those record boundaries. UNC captures every accepted field and array element
through its descriptor into an immutable snapshot before opening a transaction
or invoking a callback, so caller-side getters and later mutations cannot
change one operation midway through execution. Drizzle database and schema
objects are opaque values and are not subjected to the plain-record rule.
Public arrays must use Array.prototype directly and contain only canonical
indices from zero through 2^32 - 2 below their captured length. Subclasses,
exotic prototypes, accessors, sparse entries, and all extra properties are
rejected without invoking getters.
Validation is iterative and bounded to 256 nesting levels and 10,000 total JSON
nodes per checked value. It inspects own property descriptors without invoking
getters, rejects symbol/non-canonical/extra array properties, and copies accepted
values through descriptors before persistence. A present channelData must be
a plain own-key JSON object; null, false, 0, and empty strings are invalid
rather than being treated as absence.
Recipients and deliveries are inserted and read in bulk. The number of database query waves stays constant as recipient/channel count grows, while returned recipients preserve first-seen input order and channels remain unique.
A core constructed with a Drizzle PgTransaction is publish-only. This lets an
application commit its own row and publish() atomically. Do not call
publishAndDispatch(), dispatchUntilIdle(), or dispatchDue() on that core:
all fail before writing or invoking an adapter. After the outer transaction
commits, dispatch with a core constructed from the top-level PgDatabase.
For scheduled delivery, retries, or queue-style workers, call
dispatchUntilIdle() from infrastructure the application already owns:
const result = await notificationCore.dispatchUntilIdle({
workerId: process.env.HOSTNAME,
limit: 100,
concurrency: 10,
maxBatches: 25,
deadlineMs: 30_000,
});dispatchDue() accepts limit from 1 through 1000 and concurrency from 1
through 100; the limit is a per-call worker claim cap. dispatchUntilIdle()
uses the same limit and concurrency boundaries, keeps one stable worker id for
its drain, stops when no due rows remain, and also stops on maxBatches,
deadlineMs, a high-failure batch, or a channel policy deferral. By default a
batch stops the drain when at least 80% of attempted deliveries fail or
schedule a retry, which prevents a broken provider from being hammered in a
tight loop. When due rows exist but a channel rate limit or cooldown blocks
claiming them, the stop reason is rate-limited and deferredUntil reports
the earliest known channel wake-up time. publishAndDispatch() accepts the
same concurrency range and uses capped internal claim pages until every due
delivery from its own publication has been attempted once, unless a configured
channel policy defers some rows. Runtime values must be finite positive
integers; invalid values fail before publishing, claiming, or calling a
provider.
This can run from a cron handler, queue consumer, scheduled function, or an
application-owned interval. The package starts no timer and exposes no port.
Concurrent workers claim rows with FOR UPDATE SKIP LOCKED; abandoned
processing rows become eligible again after the configured lock timeout.
workerId is diagnostic (maximum 154 characters). Every claimed delivery gets
a separate claim token, so two attempts from the same worker cannot finalize
each other's rows.
Channel dispatch policies
UNC can enforce provider-agnostic channel policy without knowing the provider SDK. Configure static channel rate windows and circuit-breaker cooldowns when creating the core:
const notificationCore = createNotificationCore<AppChannel>({
db,
adapters,
channelPolicies: {
sms: {
rateLimit: { max: 30, windowMs: 60_000 },
circuitBreaker: {
failureRatio: 0.8,
minAttempts: 20,
cooldownMs: 5 * 60_000,
},
},
email: {
rateLimit: { max: 500, windowMs: 60_000 },
},
},
});Rate limits are durable per channel and shared by concurrent workers through
the unc_dispatch_state table. A row that is deferred by channel policy is not
claimed and does not consume an attempt. Other channels keep dispatching in the
same worker call.
Circuit breakers are also channel-scoped and durable. After a batch for one
channel reaches the configured failure ratio and minimum attempted deliveries,
UNC opens that channel's cooldown. Claimed rows still finalize normally as
sent, retrying, or failed; the cooldown only prevents another immediate
wave from hammering the same provider boundary.
Adapters can also return provider throttle information without coupling UNC to the provider:
throw new NotificationDeliveryError("rate_limited", "Provider throttled", {
retryAfterMs: 120_000,
});retryAfterMs schedules the failed delivery's next attempt and opens the same
durable channel cooldown for fresh due rows. It is a finite non-negative number
no greater than 30 days. It does not replace provider-specific credentials,
contact lookup, account management, or webhooks; those remain in the adapter and
application.
Preferences
Preferences are overrides, not a copied matrix. This keeps storage small when new topics or channels are introduced.
await notificationCore.setPreference({
recipientId: userId,
topic: "marketing.newsletter",
channel: "email",
enabled: false,
});Wildcard overrides are supported:
import { PREFERENCE_WILDCARD } from "unified-notification-core";
// Disable every SMS notification for this recipient.
await notificationCore.setPreference({
recipientId: userId,
topic: PREFERENCE_WILDCARD,
channel: "sms",
enabled: false,
});Resolution is deterministic, from most specific to least specific:
- exact topic + exact channel;
- exact topic +
*; *+ exact channel;*+*;- configured topic default;
- configured global channel default;
defaultPreferenceEnabled.
Configure application defaults when creating UNC:
const notificationCore = createNotificationCore<AppChannel>({
db,
adapters,
preferenceDefaults: {
"*": { in_app: true, email: true, sms: false, push: true },
"security.password_changed": {
in_app: true,
email: true,
push: true,
},
},
});Recipient, topic, and channel keys are trimmed at the public boundary, including
resolvePreference() and configured defaults. Keys that become empty are
rejected. Configuration also fails fast when two raw default keys normalize to
the same topic or channel, rather than silently choosing one.
Reads remain compatible with 0.1.0 preference rows whose stored keys contain
surrounding whitespace. If multiple stored rows collapse to one canonical
override, opt-out wins: any enabled: false row disables that topic/channel.
Writing or clearing that override atomically consolidates its legacy duplicates.
Channel-keyed configuration is copied into own-key registries, so names such as
toString, constructor, and __proto__ have no inherited behavior and work
only when explicitly configured.
Batch publication loads preferences in one exact indexed query plus at most one legacy canonical fallback query for the whole recipient batch. It does not scan preferences once per recipient.
Use preferencePolicy: "ignore" only for notifications that legally or
operationally must be delivered regardless of opt-out:
await notificationCore.publish({
recipientIds: [userId],
topic: "security.password_changed",
preferencePolicy: "ignore",
channels: ["in_app", "email"],
payload,
});The only accepted runtime policy values are "respect" and "ignore".
Omitting the property defaults to "respect"; an explicitly supplied null,
undefined, or any other value is a NotificationConfigurationError before
the publication transaction starts.
In-app inbox
const page = await notificationCore.listInbox({
recipientId: userId,
limit: 30,
});
const nextPage = page.nextCursor
? await notificationCore.listInbox({
recipientId: userId,
cursor: page.nextCursor,
})
: null;
await notificationCore.markRead({
notificationId,
recipientId: userId,
});
await notificationCore.archive({
notificationId,
recipientId: userId,
});
const unread = await notificationCore.unreadCount(userId);Inbox mutations always require the recipient id as well as the notification id, so a route can scope changes to its authenticated user. Inbox recipient ids use the same trim-and-reject-empty normalization as publication and preferences.
Retry behavior
Adapters signal a permanent provider/application rejection with
NotificationDeliveryError:
import { NotificationDeliveryError } from "unified-notification-core";
throw new NotificationDeliveryError(
"recipient_unreachable",
"The recipient has no verified phone number",
{ retryable: false },
);NotificationDeliveryError accepts omitted options or a plain/null-prototype
options record. If retryable is present it must be an exact boolean; only
omission defaults it to true. If retryAfterMs is present it must be a finite
non-negative number no greater than 30 days. The code is trimmed, non-empty,
PostgreSQL-safe, and at most 191 characters. The message is non-empty,
PostgreSQL-safe, and at most 10,000 characters. Options are descriptor-captured
once before the Error is constructed.
Unknown errors are retryable. The default backoff is 1 minute, 5 minutes,
15 minutes, 1 hour, then 6 hours. Configure retryDelayMs and
defaultMaxAttempts for application policy. A failed delivery can be explicitly
requeued with requeueDelivery(). Attempts are durably incremented when a row
is claimed, before the adapter runs. requeueDelivery() preserves that counter
by default and therefore requires remaining attempt capacity; pass a larger
maxAttempts, or set resetAttempts: true to start a new attempt budget.
Only a failed delivery whose notification is not canceled can be requeued.
Constructor defaults, scalar or per-channel publish overrides, and requeue
overrides all accept maxAttempts from 1 through 2,147,483,647, matching the
positive PostgreSQL integer range. Larger values fail before a transaction or
claim, and claim predicates prevent attempts from overflowing that range.
retryDelayMs may return a number or an exact same-realm native Node
Promise<number>. UNC installs the deadline before invoking the policy and
awaits it inside that boundary for at most 100 ms measured with a trusted
monotonic clock. Synchronous policy or Promise handling that crosses the
deadline cannot submit a late value. The resolved value must be finite,
non-negative, and produce a valid retry timestamp. Synchronous throws,
asynchronous rejections, never-settling Promises, timeouts, and
invalid values are consumed and logged before the deterministic built-in
backoff is used; the provider failure is still durably finalized and never left
stuck in processing. Late policy rejections are consumed. Logger callbacks may
return void or an exact native Promise<void>. UNC observes that Promise
immediately without awaiting it, so rejection is consumed while dispatch remains
independent. The logger callback itself is synchronous application code and must
return promptly.
The monotonic clock, timer functions, Promise constructor/prototype/then,
Promise brand check, and required Object/Reflect inspection operations are
captured and bound when the module initializes. Deadline settlement is installed
before application policy code runs. For an accepted native Promise, rejection
handling is attached before UNC checks whether synchronous policy work already
crossed the deadline. Fulfillment and rejection callbacks read the captured
monotonic clock before settling the deadline, so a timer starved by blocking
event-loop work cannot admit a value produced after 100 ms. UNC does not depend
on mutable public Promise.race.
Arbitrary PromiseLike values, custom thenables, cross-realm Promises, Promise
subclasses, and genuine Promises with own then/constructor hooks are invalid.
UNC uses Node's hook-free Promise brand check plus the captured exact native
prototype/intrinsics and never reads or calls an invalid value's then property.
An invalid retry result uses deterministic fallback; an invalid logger result is
ignored. The caller remains responsible for any rejected Promise hidden inside
an invalid object.
If a provider callback already has its own durable queue, returning after a successful enqueue is correct. UNC then tracks acceptance by that queue; provider delivery events may be stored in the adapter's own tables.
After an adapter resolves, provider success is final even if its optional return
value is malformed. providerMessageId, when present, must be a non-empty
string of at most 10,000 characters. metadata, when present, must be a finite
recursive JSON object under the same rules as publish input. If either field is
invalid, UNC discards the entire optional adapter result, durably marks the
delivery sent, stores invalid_adapter_result diagnostics, and logs the
problem without invoking the provider again.
Optional adapter strings and metadata must obey the same PostgreSQL-safe string
contract. Invalid NUL or unpaired-surrogate output is discarded through the
same sent-safe path. UNC captures the finalization timestamp with the claim,
before calling the provider, so a later failure in an application-supplied
now() callback cannot turn provider acceptance into a resend risk.
Diagnostic extraction never invokes error getters, toString, or coercion
hooks. Persisted errorMessage, logger context, and returned DispatchResult
share one 10,000-character cap, including malformed adapter-result diagnostics;
unsafe PostgreSQL characters are replaced before the diagnostic is persisted.
Idempotency and scheduling
An idempotencyKey is unique per recipient. Publishing the same key again
returns the original notification and deliveries with created: false.
scheduledFor delays all selected channels. cancel(notificationId) cancels
pending/retrying/processing deliveries. A provider callback already in flight
cannot be recalled: success from its current claim is persisted as sent
(sent is immutable audit history), while failure stays canceled and is not
retried. A stale callback never overwrites the state written by a newer claim,
and its DispatchResult reports the durable state it observed.
Package guarantees
- No provider SDK dependencies.
- No database connection ownership.
- No HTTP framework coupling.
- No background timers or service runtime.
- No assumption that recipient ids are UUIDs.
- No assumption that every notification is in-app.
- Provider-agnostic channel rate/cooldown state is stored in UNC tables, not in process memory.
- Provider callbacks are invoked only by a top-level-database core, after the claim transaction commits.
- Preferences disabled at publication are recorded as
skippeddeliveries for auditability.
See Architecture and Adoption notes for the design and migration boundary.
