npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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-orm

The 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:

  1. exact topic + exact channel;
  2. exact topic + *;
  3. * + exact channel;
  4. * + *;
  5. configured topic default;
  6. configured global channel default;
  7. 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 skipped deliveries for auditability.

See Architecture and Adoption notes for the design and migration boundary.