unified-notification-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Established the UNC (Unified Notification Core) package identity.
6
+ - Initial Drizzle/PostgreSQL schema factory.
7
+ - Durable multichannel delivery with callback adapters, retries, scheduling,
8
+ idempotency, and worker-safe claims.
9
+ - Recipient preference overrides with topic/channel wildcard precedence.
10
+ - Built-in in-app inbox operations.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Unified Notification Core contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,308 @@
1
+ # UNC — Unified Notification Core
2
+
3
+ A transport-neutral notification core for TypeScript applications using Drizzle
4
+ and PostgreSQL.
5
+
6
+ It is a package, not a microservice. Your application keeps its database
7
+ connection, HTTP framework, user model, provider SDKs, and worker lifecycle.
8
+ UNC adds durable notification records, per-channel delivery state,
9
+ retries, scheduling, idempotency, recipient preferences, and an in-app inbox.
10
+
11
+ ## Why this boundary
12
+
13
+ Email, SMS, and Push implementations are application-specific. One application
14
+ may send mail through Nodemailer and Postfix, another through Resend; one may
15
+ send SMS through an Android gateway, another through Twilio. This package does
16
+ not choose.
17
+
18
+ Instead, register callbacks:
19
+
20
+ ```ts
21
+ const notificationCore = createNotificationCore<AppChannel>({
22
+ db,
23
+ adapters: {
24
+ email: async ({ recipientId, payload }) => {
25
+ const recipient = await users.findContact(recipientId);
26
+ const result = await sendMail({
27
+ to: recipient.email,
28
+ subject: payload.title,
29
+ text: payload.body,
30
+ });
31
+ return { providerMessageId: result.messageId };
32
+ },
33
+ sms: async ({ recipientId, payload }) => {
34
+ const recipient = await users.findContact(recipientId);
35
+ const result = await sendSMS({
36
+ to: recipient.phone,
37
+ text: payload.body,
38
+ });
39
+ return { providerMessageId: result.id };
40
+ },
41
+ push: async ({ recipientId, payload }) => {
42
+ const result = await sendPush(recipientId, payload);
43
+ return { metadata: { sentDevices: result.sent } };
44
+ },
45
+ },
46
+ defaultChannels: ["in_app", "push"],
47
+ });
48
+ ```
49
+
50
+ The adapter resolves addresses/subscriptions at delivery time. Email addresses,
51
+ phone numbers, Push subscriptions, provider credentials, and provider-specific
52
+ retry queues stay where they belong: in the application.
53
+
54
+ ## Install
55
+
56
+ ```sh
57
+ npm install unified-notification-core drizzle-orm
58
+ ```
59
+
60
+ The package supports Drizzle `0.45.x` with PostgreSQL-compatible drivers,
61
+ including `node-postgres`, `postgres.js`, and PGlite.
62
+
63
+ ## Add the schema
64
+
65
+ Export the UNC tables from the schema entrypoint already read by Drizzle Kit:
66
+
67
+ ```ts
68
+ // src/db/notification-schema.ts
69
+ import { createNotificationSchema } from "unified-notification-core/schema";
70
+
71
+ export const notificationSchema = createNotificationSchema();
72
+
73
+ export const {
74
+ notifications,
75
+ notificationDeliveries,
76
+ notificationPreferences,
77
+ } = notificationSchema;
78
+ ```
79
+
80
+ Pass the same schema object to UNC:
81
+
82
+ ```ts
83
+ import { createNotificationCore } from "unified-notification-core";
84
+ import { notificationSchema } from "./db/notification-schema.js";
85
+
86
+ const notificationCore = createNotificationCore<AppChannel>({
87
+ db,
88
+ schema: notificationSchema,
89
+ adapters,
90
+ });
91
+ ```
92
+
93
+ The default table prefix is `unc_`. A different prefix can be selected once,
94
+ when defining the application schema:
95
+
96
+ ```ts
97
+ createNotificationSchema({ tablePrefix: "app_notification_" });
98
+ ```
99
+
100
+ Run the application's normal Drizzle schema workflow after exporting the
101
+ tables. The package never connects to or mutates a database by itself.
102
+
103
+ ## Define channels
104
+
105
+ Channel names are plain text, not a PostgreSQL enum. Adding a future channel
106
+ does not require a core release or enum migration:
107
+
108
+ ```ts
109
+ type AppChannel =
110
+ | "in_app"
111
+ | "email"
112
+ | "sms"
113
+ | "push"
114
+ | "whatsapp"
115
+ | "slack";
116
+ ```
117
+
118
+ `in_app` is built in. Every other channel must have an adapter, which makes a
119
+ missing provider integration fail at application startup instead of silently
120
+ dropping messages.
121
+
122
+ ## Publish and deliver
123
+
124
+ ```ts
125
+ const result = await notificationCore.publishAndDispatch({
126
+ recipientIds: [userId],
127
+ topic: "call.incoming",
128
+ channels: ["in_app", "push"],
129
+ idempotencyKey: `call:${callId}:incoming`,
130
+ entityType: "call",
131
+ entityId: callId,
132
+ payload: {
133
+ title: "Incoming call",
134
+ body: "A customer is waiting.",
135
+ actionUrl: `/calls/${callId}`,
136
+ data: { callId },
137
+ channelData: {
138
+ push: { tag: `call-${callId}`, ttl: 60 },
139
+ },
140
+ },
141
+ });
142
+ ```
143
+
144
+ `publish()` only persists. `publishAndDispatch()` persists and immediately
145
+ attempts due deliveries. Both use the same durable outbox records.
146
+
147
+ For scheduled delivery or retries, call `dispatchDue()` from infrastructure the
148
+ application already owns:
149
+
150
+ ```ts
151
+ await notificationCore.dispatchDue({
152
+ workerId: process.env.HOSTNAME,
153
+ limit: 100,
154
+ concurrency: 10,
155
+ });
156
+ ```
157
+
158
+ This can run from a cron handler, queue consumer, scheduled function, or an
159
+ application-owned interval. The package starts no timer and exposes no port.
160
+ Concurrent workers claim rows with `FOR UPDATE SKIP LOCKED`; abandoned
161
+ `processing` rows become eligible again after the configured lock timeout.
162
+
163
+ ## Preferences
164
+
165
+ Preferences are overrides, not a copied matrix. This keeps storage small when
166
+ new topics or channels are introduced.
167
+
168
+ ```ts
169
+ await notificationCore.setPreference({
170
+ recipientId: userId,
171
+ topic: "marketing.newsletter",
172
+ channel: "email",
173
+ enabled: false,
174
+ });
175
+ ```
176
+
177
+ Wildcard overrides are supported:
178
+
179
+ ```ts
180
+ import { PREFERENCE_WILDCARD } from "unified-notification-core";
181
+
182
+ // Disable every SMS notification for this recipient.
183
+ await notificationCore.setPreference({
184
+ recipientId: userId,
185
+ topic: PREFERENCE_WILDCARD,
186
+ channel: "sms",
187
+ enabled: false,
188
+ });
189
+ ```
190
+
191
+ Resolution is deterministic, from most specific to least specific:
192
+
193
+ 1. exact topic + exact channel;
194
+ 2. exact topic + `*`;
195
+ 3. `*` + exact channel;
196
+ 4. `*` + `*`;
197
+ 5. configured topic default;
198
+ 6. configured global channel default;
199
+ 7. `defaultPreferenceEnabled`.
200
+
201
+ Configure application defaults when creating UNC:
202
+
203
+ ```ts
204
+ const notificationCore = createNotificationCore<AppChannel>({
205
+ db,
206
+ adapters,
207
+ preferenceDefaults: {
208
+ "*": { in_app: true, email: true, sms: false, push: true },
209
+ "security.password_changed": {
210
+ in_app: true,
211
+ email: true,
212
+ push: true,
213
+ },
214
+ },
215
+ });
216
+ ```
217
+
218
+ Use `preferencePolicy: "ignore"` only for notifications that legally or
219
+ operationally must be delivered regardless of opt-out:
220
+
221
+ ```ts
222
+ await notificationCore.publish({
223
+ recipientIds: [userId],
224
+ topic: "security.password_changed",
225
+ preferencePolicy: "ignore",
226
+ channels: ["in_app", "email"],
227
+ payload,
228
+ });
229
+ ```
230
+
231
+ ## In-app inbox
232
+
233
+ ```ts
234
+ const page = await notificationCore.listInbox({
235
+ recipientId: userId,
236
+ limit: 30,
237
+ });
238
+
239
+ const nextPage = page.nextCursor
240
+ ? await notificationCore.listInbox({
241
+ recipientId: userId,
242
+ cursor: page.nextCursor,
243
+ })
244
+ : null;
245
+
246
+ await notificationCore.markRead({
247
+ notificationId,
248
+ recipientId: userId,
249
+ });
250
+
251
+ await notificationCore.archive({
252
+ notificationId,
253
+ recipientId: userId,
254
+ });
255
+
256
+ const unread = await notificationCore.unreadCount(userId);
257
+ ```
258
+
259
+ Inbox mutations always require the recipient id as well as the notification id,
260
+ so a route can scope changes to its authenticated user.
261
+
262
+ ## Retry behavior
263
+
264
+ Adapters signal a permanent provider/application rejection with
265
+ `NotificationDeliveryError`:
266
+
267
+ ```ts
268
+ import { NotificationDeliveryError } from "unified-notification-core";
269
+
270
+ throw new NotificationDeliveryError(
271
+ "recipient_unreachable",
272
+ "The recipient has no verified phone number",
273
+ { retryable: false },
274
+ );
275
+ ```
276
+
277
+ Unknown errors are retryable. The default backoff is 1 minute, 5 minutes,
278
+ 15 minutes, 1 hour, then 6 hours. Configure `retryDelayMs` and
279
+ `defaultMaxAttempts` for application policy. A failed delivery can be explicitly
280
+ requeued with `requeueDelivery()`.
281
+
282
+ If a provider callback already has its own durable queue, returning after a
283
+ successful enqueue is correct. UNC then tracks acceptance by that
284
+ queue; provider delivery events may be stored in the adapter's own tables.
285
+
286
+ ## Idempotency and scheduling
287
+
288
+ An `idempotencyKey` is unique per recipient. Publishing the same key again
289
+ returns the original notification and deliveries with `created: false`.
290
+
291
+ `scheduledFor` delays all selected channels. `cancel(notificationId)` cancels
292
+ pending/retrying/processing deliveries. Sent deliveries remain immutable audit
293
+ history.
294
+
295
+ ## Package guarantees
296
+
297
+ - No provider SDK dependencies.
298
+ - No database connection ownership.
299
+ - No HTTP framework coupling.
300
+ - No background timers or service runtime.
301
+ - No assumption that recipient ids are UUIDs.
302
+ - No assumption that every notification is in-app.
303
+ - Provider callbacks are invoked outside database transactions.
304
+ - Preferences disabled at publication are recorded as `skipped` deliveries for
305
+ auditability.
306
+
307
+ See [Architecture](./docs/architecture.md) and
308
+ [Adoption notes](./docs/adoption-notes.md) for the design and migration boundary.
package/dist/core.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type { PgDatabase } from "drizzle-orm/pg-core";
2
+ import { type NotificationSchema } from "./schema.js";
3
+ import { PREFERENCE_WILDCARD, type DeliveryAdapters, type DispatchResult, type NotificationLogger, type InboxCursor, type InboxPage, type JsonObject, type PreferenceDefaults, type PreferenceOverride, type PublishInput, type PublishedNotification, type ResolvedPreference } from "./types.js";
4
+ type AnyPgDatabase = PgDatabase<any, any, any>;
5
+ export type NotificationCoreConfig<TChannel extends string, TData extends JsonObject = JsonObject> = {
6
+ db: AnyPgDatabase;
7
+ schema?: NotificationSchema;
8
+ adapters?: DeliveryAdapters<TChannel, TData>;
9
+ defaultChannels?: readonly TChannel[];
10
+ preferenceDefaults?: PreferenceDefaults<TChannel>;
11
+ defaultPreferenceEnabled?: boolean;
12
+ defaultMaxAttempts?: number;
13
+ retryDelayMs?: (attempt: number, error: unknown) => number;
14
+ lockTimeoutMs?: number;
15
+ now?: () => Date;
16
+ logger?: NotificationLogger;
17
+ };
18
+ export declare class UnifiedNotificationCore<TChannel extends string, TData extends JsonObject = JsonObject> {
19
+ #private;
20
+ constructor(config: NotificationCoreConfig<TChannel, TData>);
21
+ publish(input: PublishInput<TChannel, TData>): Promise<Array<PublishedNotification<TChannel>>>;
22
+ publishAndDispatch(input: PublishInput<TChannel, TData>, options?: {
23
+ workerId?: string;
24
+ concurrency?: number;
25
+ }): Promise<{
26
+ published: Array<PublishedNotification<TChannel>>;
27
+ dispatched: Array<DispatchResult<TChannel>>;
28
+ }>;
29
+ dispatchDue(options?: {
30
+ limit?: number;
31
+ workerId?: string;
32
+ concurrency?: number;
33
+ }): Promise<Array<DispatchResult<TChannel>>>;
34
+ setPreference(preference: PreferenceOverride<TChannel>): Promise<void>;
35
+ setPreferences(recipientId: string, preferences: ReadonlyArray<Omit<PreferenceOverride<TChannel>, "recipientId">>): Promise<void>;
36
+ clearPreference(input: {
37
+ recipientId: string;
38
+ topic: string;
39
+ channel: TChannel | typeof PREFERENCE_WILDCARD;
40
+ }): Promise<boolean>;
41
+ getPreferences(input: {
42
+ recipientId: string;
43
+ topics: readonly string[];
44
+ channels: readonly TChannel[];
45
+ }): Promise<Array<ResolvedPreference<TChannel>>>;
46
+ listInbox(input: {
47
+ recipientId: string;
48
+ limit?: number;
49
+ cursor?: InboxCursor;
50
+ includeArchived?: boolean;
51
+ }): Promise<InboxPage<TData>>;
52
+ unreadCount(recipientId: string): Promise<number>;
53
+ markRead(input: {
54
+ notificationId: string;
55
+ recipientId: string;
56
+ read?: boolean;
57
+ }): Promise<boolean>;
58
+ archive(input: {
59
+ notificationId: string;
60
+ recipientId: string;
61
+ archived?: boolean;
62
+ }): Promise<boolean>;
63
+ cancel(notificationId: string): Promise<boolean>;
64
+ requeueDelivery(input: {
65
+ deliveryId: string;
66
+ resetAttempts?: boolean;
67
+ maxAttempts?: number;
68
+ }): Promise<boolean>;
69
+ }
70
+ export declare function createNotificationCore<TChannel extends string, TData extends JsonObject = JsonObject>(config: NotificationCoreConfig<TChannel, TData>): UnifiedNotificationCore<TChannel, TData>;
71
+ export {};
72
+ //# sourceMappingURL=core.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"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"}