@devmonks/notify 0.0.0-stage → 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dev Monks
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 CHANGED
@@ -1,3 +1,139 @@
1
- # Temporary Holding Version
1
+ # @devmonks/notify
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Generic notifications for any fetch + WebCrypto runtime (Node 20+, Bun, Workers, Deno). Two steps: **plan** (pure, decides who gets what on which channel) then **send** (one delivery through a channel adapter, one attempt). The app decides when `send` runs (inline, `waitUntil`, a queue) and whether to retry. The core has no database, no queue, no env vars and no dependencies.
4
+
5
+ | Entry | What | Needs |
6
+ | --- | --- | --- |
7
+ | `@devmonks/notify` | notifier, FCM push, in-app, quiet hours | nothing |
8
+ | `@devmonks/notify/email` | Resend adapter, React Email layout and templates | `react`, `react-dom`, `@react-email/components`, `@react-email/render` |
9
+ | `@devmonks/notify/drizzle` | Postgres tables and a store | `drizzle-orm` |
10
+
11
+ The extra entries are optional peer dependencies: install them only if you import that entry. `/email` contains `.tsx`, so it needs `"jsx": "react-jsx"` in your tsconfig.
12
+
13
+ ## Use it in a new project (push only)
14
+
15
+ 1. `bun add @devmonks/notify` (or npm/pnpm). It ships compiled ESM with types and needs Node 20+ (or Bun, Workers, Deno).
16
+ 2. In Firebase console: Project settings, Service accounts, Generate new private key. Pass `project_id`, `client_email` and `private_key` from that JSON to `fcmPush` (from your own config; the package reads no env vars).
17
+ 3. Send and classify:
18
+
19
+ ```ts
20
+ import { createNotifier, defineNotification, fcmPush } from '@devmonks/notify';
21
+
22
+ const priceAlert = defineNotification<{ symbol: string; price: string; alertId: string }>({
23
+ type: 'alert.price',
24
+ category: 'alerts', // the preference key; use the type name for per-type switches
25
+ channels: ['push'],
26
+ // transactional: true -> always send: ignores preferences and quiet hours
27
+ render: (d) => ({
28
+ title: `${d.symbol} hit ${d.price}`,
29
+ body: 'Tap to open the alert.',
30
+ url: `/alerts/${d.alertId}`, // sent as data.url for tap navigation
31
+ data: { alertId: d.alertId },
32
+ push: { badge: 1, collapseKey: `alert-${d.alertId}` },
33
+ }),
34
+ });
35
+
36
+ const notifier = createNotifier({
37
+ channels: [fcmPush({ projectId, clientEmail, privateKey, defaults: { channelId: 'alerts' } })],
38
+ types: [priceAlert],
39
+ });
40
+
41
+ // recipient comes from your own tables
42
+ const recipient = { id: user.id, locale: 'en', timeZone: 'UTC', devices: [{ id: device.id, token: device.fcmToken }], prefs: {} };
43
+ const deliveries = notifier.plan('alert.price', recipient, data, { dedupeKey: alertEventId });
44
+
45
+ for (const d of deliveries) {
46
+ // d.idempotencyKey is stable for the same event + recipient + channel + token: use it as your queue job id
47
+ const r = await notifier.send(d); // never throws
48
+ if (r.ok) continue;
49
+ if (r.deadAddress) await deleteDevice(d.deviceId); // token is gone for good
50
+ else if (r.retryable) throw new Error(r.error); // let your queue retry (429, 5xx, network)
51
+ // else: permanent failure (bad payload, wrong credentials): log r.error, do not retry
52
+ }
53
+ ```
54
+
55
+ `Delivery` is plain JSON, so you can enqueue it and call `notifier.send(d)` in the worker.
56
+
57
+ ## Plan rules
58
+
59
+ - `prefs[category][channel] === false` switches a channel off; anything else is on.
60
+ - Preferences are per category. Give each type its own category for per-type switches.
61
+ - `transactional` types ignore prefs and quiet hours.
62
+ - Quiet hours (`{ from: 'HH:MM', to: 'HH:MM' }`, recipient time zone, may cross midnight) drop push and sms; in-app and email stay.
63
+ - Push fans out one delivery per `recipient.devices[]` entry (`delivery.deviceId` is set, `delivery.to` is the token).
64
+ - Channels with no address (no email, no devices) or no registered adapter are skipped.
65
+ - `idempotencyKey = hash(type, recipient.id, channel, to, dedupeKey)`. Pass an event id as `dedupeKey` for anything repeatable; omitting it means "once ever".
66
+
67
+ ## Adapters
68
+
69
+ | Channel | Adapters |
70
+ | --- | --- |
71
+ | push | `fcmPush({ projectId, clientEmail, privateKey, defaults?, fetch? })` FCM HTTP v1 (iOS through FCM's apns block), RS256 JWT via WebCrypto, access token cached. `logPush(logger)` |
72
+ | inApp | `inApp(write)` where `write(recipientId, content, ctx)` is yours |
73
+ | sms | `logSms(logger)` only (implement `Channel` for a provider) |
74
+ | email (`/email`) | `resendEmail({ apiKey, from, brand?, linkBase?, fetch? })`, `logEmail(logger)` |
75
+
76
+ A channel is `{ name, send(to, content, ctx) }`; write your own for any other provider.
77
+
78
+ ### Push options
79
+
80
+ `PushOptions` go in `fcmPush({ defaults })` for every message and in `content.push` per message (per field, the message wins).
81
+
82
+ | Option | Android | iOS (apns) |
83
+ | --- | --- | --- |
84
+ | `channelId` | `notification.channel_id` (create the channel in the app; high importance = heads-up banner) | |
85
+ | `priority` `'high'` (default) / `'normal'` | `priority` HIGH / NORMAL | `apns-priority` 10 / 5 |
86
+ | `badge` | `notification.notification_count` | `aps.badge` |
87
+ | `sound` | `notification.sound` (below Android 8) | `aps.sound`, "default" when unset |
88
+ | `threadId` | `notification.tag` | `aps.thread-id` |
89
+ | `ttlSeconds` | `ttl` | `apns-expiration` (web: `TTL` header) |
90
+ | `collapseKey` | `collapse_key` | `apns-collapse-id` |
91
+ | `dataOnly` | no `notification`; `title` and `body` are added to `data` | background push (`content-available`) |
92
+ | `raw: { android, apns, webpush }` | deep-merged over the generated message, for any other FCM field | |
93
+
94
+ `content.data` is sent as FCM `data` (strings only) with `content.url` added as `data.url`.
95
+
96
+ Results: `{ ok: true, providerId? }` or `{ ok: false, retryable, error, deadAddress? }`. `deadAddress` is set for UNREGISTERED and for INVALID_ARGUMENT that names the token. 401 (except THIRD_PARTY_AUTH_ERROR), 429, 5xx, network errors and UNAVAILABLE / INTERNAL / QUOTA_EXCEEDED are `retryable`. Everything else is permanent. Resend: 429, 5xx and network errors are `retryable`.
97
+
98
+ ## Email (optional, `@devmonks/notify/email`)
99
+
100
+ Uses React Email. With no `content.email.template`, `simpleEmail({ heading, body, code?, button? })` is built from `title`, `body` and `url`. Custom templates are `(props) => ReactElement`; the adapter passes `brand` in with your `props`. Wrap them in `BaseLayout` (brand slot: `name`, `accent`, `logoUrl`, `footer`, `supportEmail`). `renderEmail(el)` returns `{ html, text }`.
101
+
102
+ ## Using with Drizzle (optional, `@devmonks/notify/drizzle`)
103
+
104
+ The core never imports this. It ships the tables and a store so each app does not rewrite them. Table names are fixed (`notification`, `notify_device`, `notification_pref`, `notification_settings`, `notification_delivery`). The user id columns take the type of your `users.id` (`uuid` or `text`), so the foreign keys line up. If your schema needs something else, skip this entry and write your own store; `store.ts` shows what the notifier needs (a `Recipient` to plan with, a unique idempotency key, device cleanup from the send result).
105
+
106
+ ```ts
107
+ // schema.ts
108
+ import { notifyTables } from '@devmonks/notify/drizzle';
109
+ export const { notifications, devices, notificationPrefs, notificationSettings, notificationDeliveries } =
110
+ notifyTables({ users }); // your users table, for the foreign keys
111
+ // then run your own `drizzle-kit generate`
112
+
113
+ // runtime
114
+ import { drizzleStore } from '@devmonks/notify/drizzle';
115
+ const store = drizzleStore(db, { notifications, devices, notificationPrefs, notificationSettings, notificationDeliveries }, {
116
+ getUser: async (id) => ({ email, locale, timeZone }), // email etc. live in your tables
117
+ defaultPrefs: { reads: { email: false } }, // optional
118
+ devices: { pruneInactiveAfterDays: null }, // default null = never prune
119
+ });
120
+ ```
121
+
122
+ Store methods: `loadRecipient`, `writeInbox` (the inApp write), `recordDelivery` (false if the idempotency key exists: do not send), `finishDelivery`, `touchDevice` (upsert by device id; a token seen under another device id moves), `handleDeviceResult` (device cleanup from a send result) and `pruneDevices`.
123
+
124
+ Devices: one row per install (`id` is stable, the token may rotate). A dead address (`deadAddress`) deletes the device at once; any other permanent failure adds a strike and the device is deleted at 3; a success resets the count; retryable failures change nothing. `pruneDevices()` removes devices unseen for `pruneInactiveAfterDays` and does nothing while that is null.
125
+
126
+ ## Tests
127
+
128
+ `bun run test` covers the plan rules, the FCM message mapping, the Resend/FCM error mapping with mocked fetch, and that the core entry imports no package.
129
+
130
+ ## Release
131
+
132
+ 1. Bump `version` in `package.json` and commit.
133
+ 2. `git tag v<version> && git push origin main --tags`.
134
+
135
+ The `Release` workflow runs typecheck, tests and the build (`prepublishOnly`), then publishes to npm through trusted publishing, with provenance. No npm token or 2FA code is needed.
136
+
137
+ ## Licence
138
+
139
+ MIT
@@ -0,0 +1,19 @@
1
+ import type { Channel, Logger, PushOptions } from '../types.js';
2
+ export interface FcmOptions {
3
+ projectId: string;
4
+ clientEmail: string;
5
+ /** PKCS#8 PEM from the service-account JSON. Literal "\n" sequences (env vars) are accepted. */
6
+ privateKey: string;
7
+ /** Options for every message, e.g. `{ channelId: 'alerts' }`. `content.push` overrides them per message. */
8
+ defaults?: PushOptions;
9
+ /** Injected for tests; defaults to global fetch. */
10
+ fetch?: typeof fetch;
11
+ }
12
+ /**
13
+ * Push through FCM HTTP v1 (iOS goes through FCM's apns block). One attempt per call: the result says
14
+ * whether a retry may work (`retryable`) or the token is dead (`deadAddress`); retrying is the caller's job.
15
+ * The access token is cached until shortly before it expires. Needs only fetch and WebCrypto.
16
+ */
17
+ export declare function fcmPush({ projectId, clientEmail, privateKey, defaults, fetch: f }: FcmOptions): Channel;
18
+ /** Dev: prints the push instead of sending. */
19
+ export declare function logPush(logger: Logger): Channel;
@@ -0,0 +1,168 @@
1
+ import { errorMessage, retryableStatus, tryJson } from '../util.js';
2
+ const SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
3
+ const TOKEN_URL = 'https://oauth2.googleapis.com/token';
4
+ const b64url = (data) => {
5
+ const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : new Uint8Array(data);
6
+ let s = '';
7
+ for (const b of bytes)
8
+ s += String.fromCharCode(b);
9
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
10
+ };
11
+ async function importKey(pem) {
12
+ const body = pem
13
+ .replace(/\\n/g, '\n')
14
+ .replace(/-----(BEGIN|END) PRIVATE KEY-----/g, '')
15
+ .replace(/\s+/g, '');
16
+ const der = Uint8Array.from(atob(body), (c) => c.charCodeAt(0));
17
+ return crypto.subtle.importKey('pkcs8', der, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']);
18
+ }
19
+ /** Signed service-account JWT (RS256) for the OAuth2 jwt-bearer grant. */
20
+ async function signServiceAccountJwt(clientEmail, privateKey) {
21
+ const iat = Math.floor(Date.now() / 1000);
22
+ const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
23
+ const claims = b64url(JSON.stringify({ iss: clientEmail, scope: SCOPE, aud: TOKEN_URL, iat, exp: iat + 3600 }));
24
+ const key = await importKey(privateKey);
25
+ const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, new TextEncoder().encode(`${header}.${claims}`));
26
+ return `${header}.${claims}.${b64url(sig)}`;
27
+ }
28
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
29
+ /** Drops undefined values and the objects they leave empty, so optional fields can be written plainly. */
30
+ function prune(obj) {
31
+ const out = {};
32
+ for (const [k, v] of Object.entries(obj)) {
33
+ const kept = isObject(v) ? prune(v) : v;
34
+ if (kept !== undefined)
35
+ out[k] = kept;
36
+ }
37
+ return Object.keys(out).length ? out : undefined;
38
+ }
39
+ function deepMerge(base, over) {
40
+ const out = { ...base };
41
+ for (const [k, v] of Object.entries(over)) {
42
+ const b = out[k];
43
+ out[k] = isObject(b) && isObject(v) ? deepMerge(b, v) : v;
44
+ }
45
+ return out;
46
+ }
47
+ /** The FCM v1 `Message` for one token. */
48
+ function fcmMessage(token, content, o) {
49
+ const { dataOnly = false, ttlSeconds: ttl } = o;
50
+ const high = o.priority !== 'normal';
51
+ const message = prune({
52
+ token,
53
+ notification: dataOnly ? undefined : { title: content.title, body: content.body },
54
+ data: { ...(dataOnly ? { title: content.title, body: content.body } : {}), ...content.data, ...(content.url ? { url: content.url } : {}) },
55
+ android: {
56
+ priority: high ? 'HIGH' : 'NORMAL',
57
+ ttl: ttl === undefined ? undefined : `${ttl}s`,
58
+ collapse_key: o.collapseKey,
59
+ notification: dataOnly ? undefined : { channel_id: o.channelId, sound: o.sound, tag: o.threadId, notification_count: o.badge },
60
+ },
61
+ apns: {
62
+ headers: {
63
+ 'apns-push-type': dataOnly ? 'background' : undefined,
64
+ // Background pushes must be 5; 10 is the APNs default for alerts.
65
+ 'apns-priority': dataOnly || !high ? '5' : undefined,
66
+ 'apns-expiration': ttl === undefined ? undefined : String(ttl === 0 ? 0 : Math.floor(Date.now() / 1000) + ttl),
67
+ 'apns-collapse-id': o.collapseKey,
68
+ },
69
+ payload: {
70
+ aps: dataOnly
71
+ ? { 'content-available': 1, badge: o.badge }
72
+ : { badge: o.badge, sound: o.sound ?? 'default', 'thread-id': o.threadId },
73
+ },
74
+ },
75
+ webpush: { headers: { TTL: ttl === undefined ? undefined : String(ttl) } },
76
+ });
77
+ return o.raw ? deepMerge(message, o.raw) : message;
78
+ }
79
+ /**
80
+ * Push through FCM HTTP v1 (iOS goes through FCM's apns block). One attempt per call: the result says
81
+ * whether a retry may work (`retryable`) or the token is dead (`deadAddress`); retrying is the caller's job.
82
+ * The access token is cached until shortly before it expires. Needs only fetch and WebCrypto.
83
+ */
84
+ export function fcmPush({ projectId, clientEmail, privateKey, defaults, fetch: f = fetch }) {
85
+ let cached = null;
86
+ let refreshing = null;
87
+ /** Concurrent sends share one refresh. */
88
+ function accessToken() {
89
+ if (cached && cached.expiresAt - 60_000 > Date.now())
90
+ return Promise.resolve(cached.token);
91
+ return (refreshing ??= fetchAccessToken().finally(() => (refreshing = null)));
92
+ }
93
+ async function fetchAccessToken() {
94
+ const assertion = await signServiceAccountJwt(clientEmail, privateKey);
95
+ const res = await f(TOKEN_URL, {
96
+ method: 'POST',
97
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
98
+ body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion }).toString(),
99
+ });
100
+ if (!res.ok)
101
+ throw Object.assign(new Error(`oauth ${res.status}: ${(await res.text()).slice(0, 200)}`), { status: res.status });
102
+ const j = (await res.json());
103
+ cached = { token: j.access_token, expiresAt: Date.now() + (j.expires_in ?? 3600) * 1000 };
104
+ return j.access_token;
105
+ }
106
+ return {
107
+ name: 'push',
108
+ async send(token, content) {
109
+ let bearer;
110
+ try {
111
+ bearer = await accessToken();
112
+ }
113
+ catch (e) {
114
+ const status = e.status;
115
+ // A rejected service account is a config problem, not worth hammering; network/5xx is.
116
+ return { ok: false, retryable: status === undefined || retryableStatus(status), error: errorMessage(e) };
117
+ }
118
+ let res;
119
+ try {
120
+ res = await f(`https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`, {
121
+ method: 'POST',
122
+ headers: { Authorization: `Bearer ${bearer}`, 'Content-Type': 'application/json' },
123
+ body: JSON.stringify({ message: fcmMessage(token, content, { ...defaults, ...content.push }) }),
124
+ });
125
+ }
126
+ catch (e) {
127
+ return { ok: false, retryable: true, error: `network: ${errorMessage(e)}` };
128
+ }
129
+ if (res.ok) {
130
+ const j = (await res.json().catch(() => ({})));
131
+ return j.name ? { ok: true, providerId: j.name } : { ok: true };
132
+ }
133
+ // The access token was rejected: fetch a new one on the retry (unless another send already did).
134
+ if (res.status === 401 && cached?.token === bearer)
135
+ cached = null;
136
+ return fcmFailure(res.status, await res.text().catch(() => ''));
137
+ },
138
+ };
139
+ }
140
+ /**
141
+ * Dead token: UNREGISTERED, or INVALID_ARGUMENT that names the token. FCM also answers INVALID_ARGUMENT
142
+ * for a bad payload (TTL, data key, size); that is permanent but must not delete the device.
143
+ * Retryable: 401 (expired access token; not THIRD_PARTY_AUTH_ERROR, a missing APNs key), 429, 5xx and
144
+ * UNAVAILABLE / INTERNAL / QUOTA_EXCEEDED.
145
+ */
146
+ function fcmFailure(status, raw) {
147
+ const err = tryJson(raw)?.error;
148
+ const details = err?.details ?? [];
149
+ const code = details.find((d) => d.errorCode)?.errorCode ?? err?.status ?? '';
150
+ const message = err?.message ?? raw.slice(0, 300);
151
+ const badToken = details.some((d) => d.fieldViolations?.some((v) => v.field === 'message.token'));
152
+ const error = `fcm ${status} ${code}: ${message}`.trim();
153
+ if (code === 'UNREGISTERED' || (code === 'INVALID_ARGUMENT' && (badToken || /registration token/i.test(message)))) {
154
+ return { ok: false, retryable: false, error, deadAddress: true };
155
+ }
156
+ const retryable = (status === 401 && code !== 'THIRD_PARTY_AUTH_ERROR') || retryableStatus(status) || ['UNAVAILABLE', 'INTERNAL', 'QUOTA_EXCEEDED'].includes(code);
157
+ return { ok: false, retryable, error };
158
+ }
159
+ /** Dev: prints the push instead of sending. */
160
+ export function logPush(logger) {
161
+ return {
162
+ name: 'push',
163
+ async send(token, content) {
164
+ logger.info(`[push] token=${token.slice(0, 12)}… ${JSON.stringify({ title: content.title, body: content.body, url: content.url, data: content.data })}`);
165
+ return { ok: true };
166
+ },
167
+ };
168
+ }
@@ -0,0 +1,5 @@
1
+ import type { Channel, Content, Logger, SendContext } from '../types.js';
2
+ /** Dev only: SMS has no provider yet. Implement `Channel` to add one. */
3
+ export declare function logSms(logger: Logger): Channel;
4
+ /** In-app inbox: the app supplies the write (usually a DB insert). The address is the recipient id. */
5
+ export declare function inApp(write: (recipientId: string, content: Content, ctx: SendContext) => Promise<void>): Channel;
@@ -0,0 +1,26 @@
1
+ import { errorMessage } from '../util.js';
2
+ /** Dev only: SMS has no provider yet. Implement `Channel` to add one. */
3
+ export function logSms(logger) {
4
+ return {
5
+ name: 'sms',
6
+ async send(to, content) {
7
+ logger.info(`[sms] to=${to} ${content.sms?.text ?? `${content.title}: ${content.body}`}`);
8
+ return { ok: true };
9
+ },
10
+ };
11
+ }
12
+ /** In-app inbox: the app supplies the write (usually a DB insert). The address is the recipient id. */
13
+ export function inApp(write) {
14
+ return {
15
+ name: 'inApp',
16
+ async send(recipientId, content, ctx) {
17
+ try {
18
+ await write(recipientId, content, ctx ?? {});
19
+ return { ok: true };
20
+ }
21
+ catch (e) {
22
+ return { ok: false, retryable: true, error: errorMessage(e) };
23
+ }
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,2 @@
1
+ export { notifyTables, type NotifyTables } from './tables';
2
+ export { drizzleStore, MAX_DEVICE_FAILURES, type DrizzleStore, type DrizzleStoreOptions, type UserInfo } from './store';
@@ -0,0 +1,2 @@
1
+ export { notifyTables } from './tables';
2
+ export { drizzleStore, MAX_DEVICE_FAILURES } from './store';
@@ -0,0 +1,75 @@
1
+ import type { PgDatabase } from 'drizzle-orm/pg-core';
2
+ import type { Content, Delivery, Recipient, SendResult } from '../types';
3
+ import type { NotifyTables } from './tables';
4
+ /** Devices are deleted after this many consecutive permanent failures. */
5
+ export declare const MAX_DEVICE_FAILURES = 3;
6
+ export interface UserInfo {
7
+ email?: string | null;
8
+ locale?: string | null;
9
+ timeZone?: string | null;
10
+ }
11
+ export interface DrizzleStoreOptions {
12
+ /** The app's user lookup: email, locale and time zone live in the app's own tables. */
13
+ getUser(id: string): Promise<UserInfo | null>;
14
+ /** Optional batch form of `getUser` for `loadRecipients`; without it, `getUser` runs per id. */
15
+ getUsers?(ids: readonly string[]): Promise<ReadonlyMap<string, UserInfo>>;
16
+ /** Preference defaults when a user has no row, e.g. `{ reads: { email: false } }`. */
17
+ defaultPrefs?: Record<string, Partial<Record<string, boolean>>>;
18
+ devices?: {
19
+ /** Delete devices not seen for this many days when `pruneDevices()` runs. null/undefined = never prune (default). */
20
+ pruneInactiveAfterDays?: number | null;
21
+ };
22
+ }
23
+ type AnyPgDb = PgDatabase<any, any, any>;
24
+ export declare function drizzleStore(db: AnyPgDb, tables: NotifyTables, opts: DrizzleStoreOptions): {
25
+ /** Loads everything `notifier.plan` needs. Null when the app does not know the user. */
26
+ loadRecipient(userId: string): Promise<Recipient | null>;
27
+ /**
28
+ * `loadRecipient` for many users in four queries (one with `getUsers`), for fan-out batches.
29
+ * Users the app does not know are left out of the map.
30
+ */
31
+ loadRecipients(userIds: readonly string[]): Promise<Map<string, Recipient>>;
32
+ /**
33
+ * The inApp channel's write. With a `sourceId`, a second write for the same type, source and user
34
+ * is ignored. Returns whether a row was inserted.
35
+ */
36
+ writeInbox(recipientId: string, content: Content, meta?: {
37
+ type?: string;
38
+ sourceId?: string;
39
+ actorId?: string;
40
+ }): Promise<boolean>;
41
+ /** Inserts a pending delivery row. False when the idempotency key already exists (do not send again). */
42
+ recordDelivery(d: Delivery): Promise<boolean>;
43
+ finishDelivery(d: Delivery, status: "sent" | "failed" | "skipped", attempts: number, error?: string): Promise<void>;
44
+ /**
45
+ * Upserts a device by its install id. A device row that belongs to another user is only taken over
46
+ * when the caller also holds its current push token (same phone, new account); otherwise nothing
47
+ * changes and this returns false, so nobody can hijack a device id they merely know.
48
+ */
49
+ touchDevice(userId: string, deviceId: string, info: {
50
+ platform: string;
51
+ token: string;
52
+ appVersion?: string;
53
+ }): Promise<boolean>;
54
+ /**
55
+ * The device bookkeeping for one finished push delivery:
56
+ * success resets failures; a dead address (UNREGISTERED etc.) deletes the device;
57
+ * any other permanent failure counts a strike and deletes it at MAX_DEVICE_FAILURES;
58
+ * retryable failures change nothing. Deletes and strikes apply only while the device
59
+ * still holds the token that was sent to (it may have refreshed since).
60
+ */
61
+ handleDeviceResult(d: Delivery, result: SendResult): Promise<void>;
62
+ /**
63
+ * Removes devices whose `lastSeenAt` is older than [olderThanDays] (default: the
64
+ * `devices.pruneInactiveAfterDays` option). Null, undefined or non-positive means never prune.
65
+ * Returns how many were removed.
66
+ */
67
+ pruneDevices(olderThanDays?: number | null | undefined): Promise<number>;
68
+ /** Deletes inbox and delivery rows older than [olderThanDays]. Returns how many of each were removed. */
69
+ pruneHistory(olderThanDays: number): Promise<{
70
+ notifications: number;
71
+ deliveries: number;
72
+ }>;
73
+ };
74
+ export type DrizzleStore = ReturnType<typeof drizzleStore>;
75
+ export {};
@@ -0,0 +1,166 @@
1
+ import { and, eq, inArray, lt, ne, or, sql } from 'drizzle-orm';
2
+ /** Devices are deleted after this many consecutive permanent failures. */
3
+ export const MAX_DEVICE_FAILURES = 3;
4
+ export function drizzleStore(db, tables, opts) {
5
+ const { notifications, devices, notificationPrefs, notificationSettings, notificationDeliveries } = tables;
6
+ return {
7
+ /** Loads everything `notifier.plan` needs. Null when the app does not know the user. */
8
+ async loadRecipient(userId) {
9
+ return (await this.loadRecipients([userId])).get(userId) ?? null;
10
+ },
11
+ /**
12
+ * `loadRecipient` for many users in four queries (one with `getUsers`), for fan-out batches.
13
+ * Users the app does not know are left out of the map.
14
+ */
15
+ async loadRecipients(userIds) {
16
+ const ids = [...new Set(userIds)];
17
+ const recipients = new Map();
18
+ if (ids.length === 0)
19
+ return recipients;
20
+ const users = opts.getUsers
21
+ ? await opts.getUsers(ids)
22
+ : new Map((await Promise.all(ids.map(async (id) => [id, await opts.getUser(id)]))).filter((e) => e[1] !== null));
23
+ const known = ids.filter((id) => users.has(id));
24
+ if (known.length === 0)
25
+ return recipients;
26
+ const [deviceRows, prefRows, settingRows] = await Promise.all([
27
+ db.select({ userId: devices.userId, id: devices.id, token: devices.token, platform: devices.platform }).from(devices).where(inArray(devices.userId, known)),
28
+ db.select().from(notificationPrefs).where(inArray(notificationPrefs.userId, known)),
29
+ db.select().from(notificationSettings).where(inArray(notificationSettings.userId, known)),
30
+ ]);
31
+ for (const id of known) {
32
+ const user = users.get(id);
33
+ // Null-prototype maps: category and channel are stored strings, so "__proto__" must stay a plain key.
34
+ const prefs = Object.create(null);
35
+ for (const [cat, chans] of Object.entries(opts.defaultPrefs ?? {}))
36
+ prefs[cat] = Object.assign(Object.create(null), chans);
37
+ for (const p of prefRows)
38
+ if (p.userId === id)
39
+ (prefs[p.category] ??= Object.create(null))[p.channel] = p.enabled;
40
+ const settings = settingRows.find((row) => row.userId === id);
41
+ recipients.set(id, {
42
+ id,
43
+ locale: user.locale || 'en',
44
+ timeZone: user.timeZone || 'UTC',
45
+ ...(user.email ? { email: user.email } : {}),
46
+ devices: deviceRows.filter((d) => d.userId === id).map(({ id: deviceId, token, platform }) => ({ id: deviceId, token, platform })),
47
+ prefs,
48
+ ...(settings?.quietFrom && settings.quietTo ? { quietHours: { from: settings.quietFrom, to: settings.quietTo } } : {}),
49
+ });
50
+ }
51
+ return recipients;
52
+ },
53
+ /**
54
+ * The inApp channel's write. With a `sourceId`, a second write for the same type, source and user
55
+ * is ignored. Returns whether a row was inserted.
56
+ */
57
+ async writeInbox(recipientId, content, meta = {}) {
58
+ const rows = await db
59
+ .insert(notifications)
60
+ .values({
61
+ id: crypto.randomUUID(),
62
+ userId: recipientId,
63
+ type: meta.type ?? 'unknown',
64
+ title: content.title,
65
+ body: content.body,
66
+ url: content.url ?? null,
67
+ data: content.data ?? {},
68
+ sourceId: meta.sourceId ?? null,
69
+ actorId: meta.actorId ?? null,
70
+ })
71
+ .onConflictDoNothing()
72
+ .returning({ id: notifications.id });
73
+ return rows.length > 0;
74
+ },
75
+ /** Inserts a pending delivery row. False when the idempotency key already exists (do not send again). */
76
+ async recordDelivery(d) {
77
+ const rows = await db
78
+ .insert(notificationDeliveries)
79
+ .values({
80
+ id: d.id,
81
+ idempotencyKey: d.idempotencyKey,
82
+ type: d.type,
83
+ userId: d.recipientId,
84
+ channel: d.channel,
85
+ deviceId: d.deviceId ?? null,
86
+ })
87
+ .onConflictDoNothing({ target: notificationDeliveries.idempotencyKey })
88
+ .returning({ id: notificationDeliveries.id });
89
+ return rows.length > 0;
90
+ },
91
+ async finishDelivery(d, status, attempts, error) {
92
+ await db
93
+ .update(notificationDeliveries)
94
+ .set({ status, attempts, error: error ?? null })
95
+ .where(eq(notificationDeliveries.id, d.id));
96
+ },
97
+ /**
98
+ * Upserts a device by its install id. A device row that belongs to another user is only taken over
99
+ * when the caller also holds its current push token (same phone, new account); otherwise nothing
100
+ * changes and this returns false, so nobody can hijack a device id they merely know.
101
+ */
102
+ async touchDevice(userId, deviceId, info) {
103
+ // A push token lives on one install: drop it from any other device row first (the app was
104
+ // reinstalled). Holding the token is proof enough, and the token column is unique.
105
+ await db.delete(devices).where(and(eq(devices.token, info.token), ne(devices.id, deviceId)));
106
+ const rows = await db
107
+ .insert(devices)
108
+ .values({ id: deviceId, userId, platform: info.platform, token: info.token, appVersion: info.appVersion ?? '' })
109
+ .onConflictDoUpdate({
110
+ target: devices.id,
111
+ set: { userId, platform: info.platform, token: info.token, appVersion: info.appVersion ?? '', lastSeenAt: new Date(), failureCount: 0 },
112
+ setWhere: or(eq(devices.userId, userId), eq(devices.token, info.token)),
113
+ })
114
+ .returning({ id: devices.id });
115
+ return rows.length > 0;
116
+ },
117
+ /**
118
+ * The device bookkeeping for one finished push delivery:
119
+ * success resets failures; a dead address (UNREGISTERED etc.) deletes the device;
120
+ * any other permanent failure counts a strike and deletes it at MAX_DEVICE_FAILURES;
121
+ * retryable failures change nothing. Deletes and strikes apply only while the device
122
+ * still holds the token that was sent to (it may have refreshed since).
123
+ */
124
+ async handleDeviceResult(d, result) {
125
+ if (!d.deviceId)
126
+ return;
127
+ const sameToken = and(eq(devices.id, d.deviceId), eq(devices.token, d.to));
128
+ if (result.ok) {
129
+ await db.update(devices).set({ failureCount: 0 }).where(and(eq(devices.id, d.deviceId), ne(devices.failureCount, 0)));
130
+ }
131
+ else if (result.deadAddress) {
132
+ await db.delete(devices).where(sameToken);
133
+ }
134
+ else if (!result.retryable) {
135
+ const [row] = await db
136
+ .update(devices)
137
+ .set({ failureCount: sql `${devices.failureCount} + 1` })
138
+ .where(sameToken)
139
+ .returning({ n: devices.failureCount });
140
+ if (row && row.n >= MAX_DEVICE_FAILURES)
141
+ await db.delete(devices).where(eq(devices.id, d.deviceId));
142
+ }
143
+ },
144
+ /**
145
+ * Removes devices whose `lastSeenAt` is older than [olderThanDays] (default: the
146
+ * `devices.pruneInactiveAfterDays` option). Null, undefined or non-positive means never prune.
147
+ * Returns how many were removed.
148
+ */
149
+ async pruneDevices(olderThanDays = opts.devices?.pruneInactiveAfterDays) {
150
+ if (!olderThanDays || olderThanDays <= 0)
151
+ return 0;
152
+ const cutoff = new Date(Date.now() - olderThanDays * 86_400_000);
153
+ const rows = await db.delete(devices).where(lt(devices.lastSeenAt, cutoff)).returning({ id: devices.id });
154
+ return rows.length;
155
+ },
156
+ /** Deletes inbox and delivery rows older than [olderThanDays]. Returns how many of each were removed. */
157
+ async pruneHistory(olderThanDays) {
158
+ const cutoff = new Date(Date.now() - olderThanDays * 86_400_000);
159
+ const [inbox, sent] = await Promise.all([
160
+ db.delete(notifications).where(lt(notifications.createdAt, cutoff)).returning({ id: notifications.id }),
161
+ db.delete(notificationDeliveries).where(lt(notificationDeliveries.createdAt, cutoff)).returning({ id: notificationDeliveries.id }),
162
+ ]);
163
+ return { notifications: inbox.length, deliveries: sent.length };
164
+ },
165
+ };
166
+ }