@mpgd/adapter-devvit 0.12.0 → 0.13.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.
@@ -0,0 +1,257 @@
1
+ import { type CreateNotificationServiceInput, type NotificationProviderRegistry, type NotificationRemoteDeliveryAnswer, type NotificationRemoteDeliveryProvider, type NotificationService, type NotificationServiceCatalog, type NotificationSession, type NotificationStore } from '@mpgd/game-services/notifications';
2
+ import { type NotificationDeployment, type NotificationProviderRegistryEntry } from '@mpgd/platform/notifications';
3
+ import { type CreateDevvitRedisNotificationStoreOptions, type DevvitNotificationRedis } from './redis-notification-store.js';
4
+ /**
5
+ * Remote delivery on Devvit (notifications v2 RFC, "Delivery providers",
6
+ * Devvit). It runs inside the Devvit server, not the game-services Worker,
7
+ * because only that runtime can call the notifications plugin: the same
8
+ * `@mpgd/game-services/notifications` service, policy gate, and delivery
9
+ * module, over a Redis-backed store.
10
+ *
11
+ * `enqueue` and `isOptedIn` are `notifications` from `@devvit/web/server`
12
+ * (`@devvit/notifications` 0.14.1): <https://developers.reddit.com/docs/versioned_docs/version-0.14/capabilities/notifications/pn-best-practices>.
13
+ */
14
+ /** One recipient of `notifications.enqueue` (`EnqueueOptions['recipients'][number]`). */
15
+ export interface DevvitEnqueueRecipient {
16
+ readonly userId: `t2_${string}`;
17
+ readonly link: `t1_${string}` | `t3_${string}`;
18
+ readonly data: Record<string, string>;
19
+ }
20
+ /** What `notifications.enqueue` answers (`EnqueueResponse`). */
21
+ export interface DevvitEnqueueResponse {
22
+ readonly successCount: number;
23
+ readonly failureCount: number;
24
+ readonly errors: readonly {
25
+ readonly userId: string | undefined;
26
+ readonly message: string;
27
+ }[];
28
+ }
29
+ /** The server side of the Devvit notifications plugin that sends and reads opt-in. */
30
+ export interface DevvitNotificationsDeliveryClient {
31
+ enqueue(options: {
32
+ title: string;
33
+ body: string;
34
+ recipients: DevvitEnqueueRecipient[];
35
+ }): Promise<DevvitEnqueueResponse>;
36
+ isOptedIn(userId: `t2_${string}`): Promise<boolean>;
37
+ }
38
+ /**
39
+ * How an `enqueue` failure is answered: a per-recipient error of the answer
40
+ * (`source: 'answer'`: Devvit counted a failure for the user, but an error
41
+ * the defaults cannot read is still `ambiguous`) or a call that threw
42
+ * (`source: 'thrown'`, Devvit may have queued it, so `ambiguous` is the
43
+ * default and the only safe answer unless the error proves nothing was
44
+ * sent: a classifier must answer `undefined` or `ambiguous` for a thrown
45
+ * error it does not know). An answer that counts neither a success nor a
46
+ * failure is not classified. Devvit documents no error messages (checked
47
+ * 2026-10-19); a deployment that observes one (through `onEnqueueError`)
48
+ * names it here. `undefined` keeps the default.
49
+ */
50
+ export type DevvitEnqueueErrorClassifier = (message: string, source: 'answer' | 'thrown') => 'rate-limited' | 'recipient' | 'configuration' | 'ambiguous' | undefined;
51
+ /** An `enqueue` failure, for the operator's log; the message is Devvit's and may name the user. */
52
+ export interface DevvitEnqueueErrorReport {
53
+ readonly sendKey: string;
54
+ readonly source: 'answer' | 'thrown';
55
+ readonly message: string;
56
+ readonly answer: NotificationRemoteDeliveryAnswer['status'];
57
+ /** The reason of a `rejected` answer. */
58
+ readonly reason?: 'configuration' | 'recipient';
59
+ }
60
+ export interface CreateDevvitNotificationDeliveryProviderInput {
61
+ readonly notifications: DevvitNotificationsDeliveryClient;
62
+ /**
63
+ * The Reddit user id of a player, for the send-time opt-in read. By
64
+ * default a player id that is a `t2_` id (the Devvit server bridge's
65
+ * `context.userId`) is its own Reddit id.
66
+ */
67
+ readonly userIdOf?: (player: string) => string | undefined;
68
+ readonly classifyEnqueueError?: DevvitEnqueueErrorClassifier;
69
+ /**
70
+ * Every `enqueue` failure and how it was answered, so an operator learns
71
+ * Devvit's messages. `createDevvitNotificationService` forwards the
72
+ * `ambiguous` ones and the `configuration` rejections to `onError`.
73
+ */
74
+ readonly onEnqueueError?: (report: DevvitEnqueueErrorReport) => void;
75
+ }
76
+ /** Provider codes of the answers this provider gives without calling `enqueue`. */
77
+ export declare const devvitNotificationProviderCodes: Readonly<{
78
+ readonly deviceRecipient: 'DEVVIT_DEVICE_RECIPIENT';
79
+ readonly recipientInvalid: 'DEVVIT_RECIPIENT_INVALID';
80
+ readonly linkInvalid: 'DEVVIT_LINK_INVALID';
81
+ readonly textMissing: 'DEVVIT_TEXT_MISSING';
82
+ readonly titleTooLong: 'DEVVIT_TITLE_TOO_LONG';
83
+ readonly bodyTooLong: 'DEVVIT_BODY_TOO_LONG';
84
+ readonly templateSyntax: 'DEVVIT_TEMPLATE_SYNTAX';
85
+ readonly enqueueFailed: 'DEVVIT_ENQUEUE_FAILED';
86
+ readonly enqueueRejected: 'DEVVIT_ENQUEUE_REJECTED';
87
+ readonly enqueueUnanswered: 'DEVVIT_ENQUEUE_UNANSWERED';
88
+ readonly enqueueUnclassified: 'DEVVIT_ENQUEUE_UNCLASSIFIED';
89
+ }>;
90
+ /**
91
+ * The Devvit delivery provider (`{ id: 'devvit', send, readPermission }`).
92
+ * One recipient per `enqueue` call in the first wave (RFC "Open questions",
93
+ * bulk sends).
94
+ *
95
+ * - **What it sends.** The gate's title and body as they are, with the
96
+ * send's `link` (else the mapping's) and no template data. Every Mustache
97
+ * tag starts with `{{`, so a filled text that still holds one (from
98
+ * template data such as a player name, or a catalog brace outside a
99
+ * `{{name}}` placeholder) is refused (`rejected`, `configuration`, provider
100
+ * code `DEVVIT_TEMPLATE_SYNTAX`) before anything is sent.
101
+ * - **Limits it checks first.** A missing or blank title or body, a title over 60 or a body over 100 code
102
+ * points of the filled text (see `devvitNotificationTextLength`), a link
103
+ * that is not a `t3_` or `t1_` id, and a recipient that is not a `t2_` id
104
+ * are `rejected` with reason `configuration` (the recipient: `recipient`),
105
+ * because the same send would fail again.
106
+ * - **Answers.** `successCount` above 0 is `accepted`. Otherwise an error for
107
+ * this recipient (or for no named user) is a failure Devvit counted:
108
+ * a message that reads as a rate limit is `rate-limited` (deferred, for
109
+ * at most a day), one that reads as the user's refusal (not opted in, no
110
+ * such user) in an error that names this recipient is `rejected` with
111
+ * reason `recipient`. Any other error is `ambiguous` (provider code
112
+ * `DEVVIT_ENQUEUE_UNCLASSIFIED`): Devvit documents no error messages, so
113
+ * an unknown one is neither proof of a setup error nor of a refusal. The
114
+ * service reports it to the operator and replays it after each lease,
115
+ * which doubles from the claim lease up to an hour, for at most 24 hours
116
+ * after the first attempt (`notificationAmbiguousReplayHours`): with the
117
+ * default 300-second lease, at most 27 calls. A call that throws,
118
+ * or an answer that counts neither a success nor a failure, is
119
+ * `ambiguous` too: Devvit may have queued it, and `enqueue` takes no
120
+ * idempotency key, so a replay may show twice.
121
+ * `classifyEnqueueError` overrides each default, and `onEnqueueError`
122
+ * sees every failure with Devvit's message.
123
+ * - **Opt-in at send time.** `readPermission` reads `isOptedIn`, since no
124
+ * opt-out event exists and opt-out may move into Reddit settings: opted in
125
+ * is `granted`, otherwise `prompt`.
126
+ */
127
+ export declare function createDevvitNotificationDeliveryProvider(input: CreateDevvitNotificationDeliveryProviderInput): NotificationRemoteDeliveryProvider;
128
+ /**
129
+ * The Devvit registry entry: the reference `devvit` entry of
130
+ * `@mpgd/platform/notifications`, whose values this provider owns. 2 sends
131
+ * per user per day is a provider limit (a day-long window suppresses), and
132
+ * 25,000 per app per day is the `rate` the scheduler keeps to. A fresh copy
133
+ * on every call.
134
+ */
135
+ export declare function createDevvitNotificationProviderEntry(): NotificationProviderRegistryEntry;
136
+ export interface CreateDevvitNotificationServiceInput extends Omit<CreateNotificationServiceInput, 'store' | 'registry' | 'catalog' | 'resolvePlatformRecipient' | 'deployment'> {
137
+ /** `redis` from `@devvit/web/server`, for the Redis store; not needed with `store`. */
138
+ readonly redis?: DevvitNotificationRedis;
139
+ /** `notifications` from `@devvit/web/server`. */
140
+ readonly notifications: DevvitNotificationsDeliveryClient;
141
+ /** The game's topic catalog file, validated against the Devvit entry for the `reddit` target. */
142
+ readonly catalog: unknown;
143
+ readonly deployment?: NotificationDeployment;
144
+ readonly store?: NotificationStore;
145
+ readonly storeOptions?: CreateDevvitRedisNotificationStoreOptions;
146
+ readonly classifyEnqueueError?: DevvitEnqueueErrorClassifier;
147
+ }
148
+ export interface DevvitNotificationService {
149
+ readonly service: NotificationService;
150
+ readonly store: NotificationStore;
151
+ readonly registry: NotificationProviderRegistry;
152
+ readonly catalog: NotificationServiceCatalog;
153
+ }
154
+ /**
155
+ * The notifications v2 service as the Devvit server runs it: the Redis
156
+ * store, the Devvit provider for the `reddit` target, and the catalog
157
+ * checked against the Devvit entry (title ≤ 60 and body ≤ 100 code points,
158
+ * an optional `t3_`/`t1_` default link). A player is a Reddit user id
159
+ * (`context.userId`), which is also the recipient; any other player id has
160
+ * no Devvit recipient (`unavailable: no-endpoint`).
161
+ *
162
+ * Players' own calls take a `devvitNotificationSession(userId)`. Server
163
+ * reminders (`setReminder`) fire through the `dispatch` scheduler task
164
+ * (`runDevvitNotificationTask`). The scheduler pushes no consent notices
165
+ * through Devvit: its entry fixes the title and body per topic, so it does
166
+ * not carry notices (`notificationProviderCarriesNotices`).
167
+ */
168
+ export declare function createDevvitNotificationService(input: CreateDevvitNotificationServiceInput): DevvitNotificationService;
169
+ /**
170
+ * The notifications session of a Devvit request's logged-in user. A Devvit
171
+ * player has no game-services session: the user id is the player, and one
172
+ * session and installation per user, which no endpoint uses because Devvit
173
+ * registers no device address.
174
+ */
175
+ export declare function devvitNotificationSession(userId: string): NotificationSession;
176
+ /**
177
+ * The Devvit scheduler tasks that keep the service's own work moving:
178
+ * `dispatch` runs the service's scheduler (`runScheduler`) every minute,
179
+ * which turns due server reminders into sends and replays due rows
180
+ * (deferred sends, rate-limited ones, and ambiguous answers once their
181
+ * lease ends), and `purge` forgets old keys,
182
+ * history, and dead endpoints every five minutes. They run, and use two of
183
+ * the installation's recurring tasks, even while the game has no
184
+ * notifications service; such a run does no work. A game declares both in
185
+ * `devvit.json` (`scheduler.tasks`, each with its `endpoint` and `cron`)
186
+ * and routes each endpoint to `runDevvitNotificationTask`.
187
+ *
188
+ * Devvit scheduler: <https://developers.reddit.com/docs/versioned_docs/version-0.14/capabilities/server/scheduler>
189
+ * (a recurring task is a `devvit.json` `scheduler.tasks` entry whose
190
+ * endpoint matches `/internal/.+`, answered with a `TaskResponse`, `{}`).
191
+ */
192
+ export declare const devvitNotificationSchedulerTasks: Readonly<{
193
+ readonly dispatch: Readonly<{
194
+ name: "mpgd-notifications-dispatch";
195
+ endpoint: "/internal/scheduler/mpgd-notifications-dispatch";
196
+ cron: "* * * * *";
197
+ }>;
198
+ readonly purge: Readonly<{
199
+ name: "mpgd-notifications-purge";
200
+ endpoint: "/internal/scheduler/mpgd-notifications-purge";
201
+ cron: "*/5 * * * *";
202
+ }>;
203
+ }>;
204
+ export type DevvitNotificationTask = keyof typeof devvitNotificationSchedulerTasks;
205
+ /**
206
+ * Due schedule entries, and due sends, one scheduler round of a dispatch
207
+ * task takes at most (`runScheduler`'s `limit`).
208
+ */
209
+ export declare const devvitNotificationDispatchBatch = 10;
210
+ /**
211
+ * Rows of each kind one purge task deletes at most (the Redis store's
212
+ * `purgeLimit`). Every five minutes that is 57,600 a day of each kind,
213
+ * above the 25,000 sends a day Devvit allows an app.
214
+ */
215
+ export declare const devvitNotificationPurgeBatch = 200;
216
+ /**
217
+ * A dispatch task starts no scheduler round after this long
218
+ * (`runScheduler`'s `budgetMs`), so a run lasts the budget plus at most one
219
+ * round. Devvit documents no time limit for a scheduler task; this keeps a
220
+ * run well inside the next minute's.
221
+ */
222
+ export declare const devvitNotificationDispatchBudgetMs = 20000;
223
+ /** The task an internal scheduler endpoint names, or `undefined` for any other path. */
224
+ export declare function devvitNotificationTaskForEndpoint(pathname: string): DevvitNotificationTask | undefined;
225
+ export type DevvitNotificationTaskResult = {
226
+ readonly task: 'dispatch';
227
+ /**
228
+ * The sends the run touched, whatever their outcome (delivered, in
229
+ * flight, deferred, or refused by the gate): fired reminders and due rows.
230
+ */
231
+ readonly touched: number;
232
+ readonly failed: number;
233
+ } | {
234
+ readonly task: 'purge';
235
+ readonly sends: number;
236
+ readonly history: number;
237
+ readonly endpoints: number;
238
+ };
239
+ /**
240
+ * Runs one scheduler task.
241
+ *
242
+ * `dispatch` is one `runScheduler` run, bounded by time: rounds of at most
243
+ * `dispatchLimit` (10, a positive integer) due schedule entries and due
244
+ * sends, which go on while a round filled its batch and got somewhere, and
245
+ * start no round after `budgetMs` (20 seconds), so a run lasts the budget
246
+ * plus at most one round; the sends of a run are not otherwise capped. Due
247
+ * rows count against the Devvit entry's `rate`, 25,000 a day, a daily cap
248
+ * rather than a spread. One failing send never stops the others.
249
+ *
250
+ * `purge` deletes at most the store's `purgeLimit` rows of each kind (200
251
+ * for the store `createDevvitNotificationService` builds). A backlog
252
+ * drains over the following runs.
253
+ */
254
+ export declare function runDevvitNotificationTask(service: Pick<NotificationService, 'runScheduler' | 'purge'>, task: DevvitNotificationTask, options?: {
255
+ readonly dispatchLimit?: number;
256
+ readonly budgetMs?: number;
257
+ }): Promise<DevvitNotificationTaskResult>;