@mpgd/game-services 0.34.0 → 0.35.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,229 @@
1
+ import { isValidNotificationTimeZone, NotificationError, notificationKeyIssue, notificationReservedKeyPrefix, } from '@mpgd/platform/notifications';
2
+ /**
3
+ * Bulk sends (notifications v2 RFC, open question 5; BK1): the request and
4
+ * answer types of `NotificationService.deliverBulk`, and the small pieces it
5
+ * is built from. A bulk send is many single sends that share provider calls:
6
+ * each recipient keeps its own key, ledger row, gate check, and answer.
7
+ */
8
+ /** Most recipients one `deliverBulk` call takes. */
9
+ export const maxNotificationBulkRecipients = 10_000;
10
+ /** The send key of one recipient of a bulk send. */
11
+ export function notificationBulkRecipientKey(idempotencyKey, player) {
12
+ return `${idempotencyKey}:${encodeURIComponent(player)}`;
13
+ }
14
+ /**
15
+ * A well-formed bulk request, or `NOTIFICATIONS_INVALID_INPUT` for the whole
16
+ * call. Each recipient's own request (its template data, data, locale, and
17
+ * link, and its derived key) is checked later as a single send's is, and
18
+ * fails that recipient alone.
19
+ */
20
+ export function parseNotificationBulkRequest(raw) {
21
+ if (!isPlainObject(raw)) {
22
+ throw invalid('A bulk send is an object.');
23
+ }
24
+ const known = new Set([
25
+ 'idempotencyKey',
26
+ 'provider',
27
+ 'topic',
28
+ 'recipients',
29
+ 'templateData',
30
+ 'data',
31
+ 'locale',
32
+ 'link',
33
+ 'batchSize',
34
+ ]);
35
+ const unknown = Object.keys(raw).find((key) => !known.has(key));
36
+ if (unknown !== undefined) {
37
+ throw invalid(`Invalid bulk send: ${unknown}: not a bulk send field.`);
38
+ }
39
+ if (typeof raw.idempotencyKey !== 'string') {
40
+ throw invalid('Invalid bulk send: idempotencyKey: expected a string.');
41
+ }
42
+ const keyIssue = notificationKeyIssue(raw.idempotencyKey);
43
+ if (keyIssue !== undefined) {
44
+ throw invalid(`Invalid bulk send: idempotencyKey: ${keyIssue}.`);
45
+ }
46
+ const reserved = notificationReservedKeyPrefix(raw.idempotencyKey);
47
+ if (reserved !== undefined) {
48
+ throw invalid(`Invalid bulk send: idempotencyKey: the ${reserved} prefix is reserved for the scheduler.`);
49
+ }
50
+ for (const name of ['provider', 'topic']) {
51
+ if (typeof raw[name] !== 'string' || raw[name].length === 0 || raw[name].length > 2048) {
52
+ throw invalid(`Invalid bulk send: ${name}: expected a non-empty string.`);
53
+ }
54
+ }
55
+ if (raw.batchSize !== undefined
56
+ && !(typeof raw.batchSize === 'number' && Number.isSafeInteger(raw.batchSize) && raw.batchSize >= 1)) {
57
+ throw invalid('Invalid bulk send: batchSize: expected a positive integer.');
58
+ }
59
+ const recipients = raw.recipients;
60
+ if (!Array.isArray(recipients) || recipients.length === 0 || recipients.length > maxNotificationBulkRecipients) {
61
+ throw invalid(`Invalid bulk send: recipients: expected 1 to ${maxNotificationBulkRecipients} recipients.`);
62
+ }
63
+ const recipientFields = new Set(['player', 'templateData', 'data', 'locale', 'link']);
64
+ const players = new Set();
65
+ const keys = [];
66
+ for (const [index, recipient] of recipients.entries()) {
67
+ if (!isPlainObject(recipient)) {
68
+ throw invalid(`Invalid bulk send: recipients[${index}]: expected an object.`);
69
+ }
70
+ const field = Object.keys(recipient).find((key) => !recipientFields.has(key));
71
+ if (field !== undefined) {
72
+ throw invalid(`Invalid bulk send: recipients[${index}].${field}: not a recipient field.`);
73
+ }
74
+ const player = recipient.player;
75
+ if (typeof player !== 'string' || player.length === 0 || player.length > 2048) {
76
+ throw invalid(`Invalid bulk send: recipients[${index}].player: expected a non-empty string.`);
77
+ }
78
+ if (players.has(player)) {
79
+ // One send per player: a second would be a second key for the same push.
80
+ throw invalid(`Invalid bulk send: recipients[${index}].player: a player is named once.`);
81
+ }
82
+ players.add(player);
83
+ try {
84
+ keys.push(notificationBulkRecipientKey(raw.idempotencyKey, player));
85
+ }
86
+ catch {
87
+ // `encodeURIComponent` refuses a lone surrogate, which no key may hold.
88
+ throw invalid(`Invalid bulk send: recipients[${index}].player: holds a lone surrogate.`);
89
+ }
90
+ }
91
+ return { input: raw, keys };
92
+ }
93
+ /**
94
+ * A provider's bulk capability as the registry accepts it: whole numbers,
95
+ * a minimum no larger than the maximum, and a hold window of two `HH:MM`
96
+ * times in a valid time zone. Throws a `TypeError` naming the issue.
97
+ */
98
+ export function assertNotificationBulkCapability(capability, at) {
99
+ const { maxRecipients, minRecipients, perRecipientOutcomes, holdWindow } = capability;
100
+ if (!Number.isSafeInteger(maxRecipients) || maxRecipients < 1) {
101
+ throw new TypeError(`${at}.bulk.maxRecipients must be a positive integer.`);
102
+ }
103
+ if (minRecipients !== undefined
104
+ && (!Number.isSafeInteger(minRecipients) || minRecipients < 1 || minRecipients > maxRecipients)) {
105
+ throw new TypeError(`${at}.bulk.minRecipients must be a positive integer no larger than maxRecipients.`);
106
+ }
107
+ if (typeof perRecipientOutcomes !== 'boolean') {
108
+ throw new TypeError(`${at}.bulk.perRecipientOutcomes must be a boolean.`);
109
+ }
110
+ if (holdWindow !== undefined) {
111
+ if (minutesOf(holdWindow.start) === undefined
112
+ || minutesOf(holdWindow.end) === undefined
113
+ || holdWindow.start === holdWindow.end
114
+ || !isValidNotificationTimeZone(holdWindow.timeZone)) {
115
+ throw new TypeError(`${at}.bulk.holdWindow needs two different HH:MM times and a valid time zone.`);
116
+ }
117
+ }
118
+ }
119
+ /** Whether `at` (epoch milliseconds) falls inside a provider's hold window. */
120
+ export function isInsideNotificationHoldWindow(window, at) {
121
+ if (window === undefined) {
122
+ return false;
123
+ }
124
+ const start = minutesOf(window.start);
125
+ const end = minutesOf(window.end);
126
+ if (start === undefined || end === undefined) {
127
+ return false;
128
+ }
129
+ const parts = new Intl.DateTimeFormat('en-GB', {
130
+ timeZone: window.timeZone,
131
+ hour: '2-digit',
132
+ minute: '2-digit',
133
+ hourCycle: 'h23',
134
+ }).formatToParts(new Date(at));
135
+ const hour = Number(parts.find((part) => part.type === 'hour')?.value);
136
+ const minute = Number(parts.find((part) => part.type === 'minute')?.value);
137
+ const now = hour * 60 + minute;
138
+ return start < end ? now >= start && now < end : now >= start || now < end;
139
+ }
140
+ function minutesOf(time) {
141
+ const match = /^([01]\d|2[0-3]):([0-5]\d)$/u.exec(time);
142
+ return match === null ? undefined : Number(match[1]) * 60 + Number(match[2]);
143
+ }
144
+ /** A counting semaphore: at most `concurrency` holders at once, served in order. */
145
+ export function createNotificationLimiter(concurrency) {
146
+ let active = 0;
147
+ const waiting = [];
148
+ return {
149
+ acquire() {
150
+ if (active < concurrency) {
151
+ active += 1;
152
+ return Promise.resolve();
153
+ }
154
+ return new Promise((resolve) => {
155
+ waiting.push(resolve);
156
+ });
157
+ },
158
+ release() {
159
+ const next = waiting.shift();
160
+ if (next === undefined) {
161
+ active -= 1;
162
+ }
163
+ else {
164
+ // The slot passes to the next waiter.
165
+ next();
166
+ }
167
+ },
168
+ };
169
+ }
170
+ /**
171
+ * Gathers the provider calls of a known number of participants into one
172
+ * flush. Each participant either arrives with its item, and waits for its
173
+ * answer, or leaves without one; once every participant did one or the
174
+ * other, `flush` runs once with the arrived items and answers each in
175
+ * order. A participant that holds a concurrency slot gives it up before it
176
+ * waits (see `deliverBulk`), so the barrier never waits on a participant
177
+ * that cannot run.
178
+ */
179
+ export function createNotificationBatchBarrier(participants, flush) {
180
+ const arrived = [];
181
+ let left = 0;
182
+ let flushed = false;
183
+ const maybeFlush = () => {
184
+ if (flushed || arrived.length + left < participants) {
185
+ return;
186
+ }
187
+ flushed = true;
188
+ if (arrived.length === 0) {
189
+ return;
190
+ }
191
+ const items = arrived.map((entry) => entry.item);
192
+ flush(items).then((answers) => {
193
+ arrived.forEach((entry, index) => {
194
+ const answer = answers[index];
195
+ if (answer === undefined) {
196
+ entry.reject(new TypeError('The batch flush answered fewer items than it took.'));
197
+ }
198
+ else {
199
+ entry.resolve(answer);
200
+ }
201
+ });
202
+ }, (error) => {
203
+ for (const entry of arrived) {
204
+ entry.reject(error);
205
+ }
206
+ });
207
+ };
208
+ return {
209
+ arrive(item) {
210
+ if (flushed) {
211
+ return Promise.reject(new TypeError('The batch was already flushed.'));
212
+ }
213
+ return new Promise((resolve, reject) => {
214
+ arrived.push({ item, resolve, reject });
215
+ maybeFlush();
216
+ });
217
+ },
218
+ leave() {
219
+ left += 1;
220
+ maybeFlush();
221
+ },
222
+ };
223
+ }
224
+ function invalid(message) {
225
+ return new NotificationError('NOTIFICATIONS_INVALID_INPUT', { message });
226
+ }
227
+ function isPlainObject(value) {
228
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
229
+ }
@@ -0,0 +1,87 @@
1
+ import { type NotificationNoticeVariant, type NotificationResolvedCopy, type NotificationTemplateData, type NotificationTopicCopy, type NotificationTopicDeclaration } from '@mpgd/platform/notifications';
2
+ /**
3
+ * Server-side copy for remote sends (notifications v2 RFC, "Remote copy",
4
+ * open question 4). A send's title and body come from a copy resolver: by
5
+ * default the catalog's `copy` for the recipient's locale
6
+ * (`resolveCatalogNotificationCopy`), or a project's own resolver passed to
7
+ * `createNotificationService` as `resolveCopy`. The service asks the
8
+ * resolver only where the content comes from copy
9
+ * (`notificationContentSource` is `catalog`): a target mapping's own title
10
+ * and body (Devvit) and a host template (Apps in Toss) never reach it.
11
+ *
12
+ * The text never comes from the request: a send or a reminder carries no
13
+ * title or body, only template data for the `{{name}}` placeholders of the
14
+ * text the resolver answers. The policy gate then renders the answer exactly
15
+ * as it renders catalog copy, so under the Korean rules an advertising send
16
+ * still gets the `(광고)` label, the sender line, the opt-out line, and the
17
+ * opt-out action.
18
+ */
19
+ /** What a copy resolver is asked: one topic, for one recipient's locale, on one provider. */
20
+ export interface NotificationCopyResolverInput {
21
+ /**
22
+ * The topic as declared for the recipient's target, with its mapping and
23
+ * its catalog `copy`; for a consent notice, the notice topic with the
24
+ * variant's `notices.copy` as its `copy`.
25
+ */
26
+ readonly topic: NotificationTopicDeclaration;
27
+ /** The provider the send goes through. */
28
+ readonly provider: string;
29
+ /**
30
+ * The send's locale, else the recipient's registered locale; absent when
31
+ * neither names one. A resolver falls back from it as the catalog lookup
32
+ * does: the exact locale, then its language, then `defaultLocale`.
33
+ */
34
+ readonly locale?: string;
35
+ /** The catalog's `defaultLocale`. */
36
+ readonly defaultLocale: string;
37
+ /**
38
+ * The send's template data, which fills the answer's placeholders after
39
+ * the resolver answers. It may come from a player's reminder: a resolver
40
+ * must not copy it into the text it answers.
41
+ */
42
+ readonly templateData?: NotificationTemplateData;
43
+ /** Set for a consent notice the scheduler pushes: the variant whose text it sends. */
44
+ readonly notice?: {
45
+ readonly variant: NotificationNoticeVariant;
46
+ };
47
+ }
48
+ /**
49
+ * Chooses the title and body templates of one send for one locale, or
50
+ * answers `undefined` when it has none: the send is then skipped as
51
+ * `unavailable: copy-missing`, never sent with empty text. The answer's
52
+ * `{{name}}` placeholders are filled from the send's template data, as
53
+ * catalog copy is. A resolver that throws fails the attempt, and the send
54
+ * is retried: a `NotificationError` keeps its own code, and any other throw
55
+ * is a copy outage (`NOTIFICATIONS_TEMPORARILY_UNAVAILABLE`).
56
+ */
57
+ export type NotificationCopyResolver = (input: NotificationCopyResolverInput) => NotificationTopicCopy | undefined | Promise<NotificationTopicCopy | undefined>;
58
+ /**
59
+ * The default resolver: the topic's catalog `copy` for the locale, then its
60
+ * language (`en` for `en-US`), then the catalog's `defaultLocale`, each
61
+ * matched without regard to case (`findNotificationCopy`). A project's
62
+ * resolver can fall back to it for the topics or locales it does not cover:
63
+ * `(input) => mine(input) ?? resolveCatalogNotificationCopy(input)`.
64
+ */
65
+ export declare const resolveCatalogNotificationCopy: NotificationCopyResolver;
66
+ /** A resolver's answer as a send uses it, with why an answer was not usable. */
67
+ export interface NotificationCopyResolution extends NotificationResolvedCopy {
68
+ /** Set when the resolver answered something that cannot be sent; `copy` is then absent. */
69
+ readonly issue?: string;
70
+ }
71
+ /**
72
+ * Asks `resolver` once and checks the answer: a title and a body that are
73
+ * strings and not blank, and for a consent notice the notice text rules
74
+ * (`notificationNoticeTextIssues`: only `{{consentDate}}`, which one of them
75
+ * uses). An answer that fails is no copy, with the reason in `issue`. The
76
+ * result's `copy` is what the gate takes as `resolvedCopy`.
77
+ */
78
+ export declare function resolveNotificationCopy(resolver: NotificationCopyResolver, input: NotificationCopyResolverInput): Promise<NotificationCopyResolution>;
79
+ /**
80
+ * Resolves one topic for several locales, asking `resolver` once per
81
+ * distinct locale (an absent locale counts as one): a bulk send groups its
82
+ * recipients by locale and sends each group the copy resolved for it. The
83
+ * map is keyed by the locales as given.
84
+ */
85
+ export declare function resolveNotificationCopyByLocale(resolver: NotificationCopyResolver, input: Omit<NotificationCopyResolverInput, 'locale'> & {
86
+ readonly locales: Iterable<string | undefined>;
87
+ }): Promise<Map<string | undefined, NotificationCopyResolution>>;
@@ -0,0 +1,70 @@
1
+ import { findNotificationCopy, notificationNoticeTextIssues, } from '@mpgd/platform/notifications';
2
+ /**
3
+ * The default resolver: the topic's catalog `copy` for the locale, then its
4
+ * language (`en` for `en-US`), then the catalog's `defaultLocale`, each
5
+ * matched without regard to case (`findNotificationCopy`). A project's
6
+ * resolver can fall back to it for the topics or locales it does not cover:
7
+ * `(input) => mine(input) ?? resolveCatalogNotificationCopy(input)`.
8
+ */
9
+ export const resolveCatalogNotificationCopy = (input) => findNotificationCopy(input.topic, input.locale, input.defaultLocale);
10
+ /**
11
+ * Asks `resolver` once and checks the answer: a title and a body that are
12
+ * strings and not blank, and for a consent notice the notice text rules
13
+ * (`notificationNoticeTextIssues`: only `{{consentDate}}`, which one of them
14
+ * uses). An answer that fails is no copy, with the reason in `issue`. The
15
+ * result's `copy` is what the gate takes as `resolvedCopy`.
16
+ */
17
+ export async function resolveNotificationCopy(resolver, input) {
18
+ const answer = await resolver(input);
19
+ if (answer === undefined || answer === null) {
20
+ return {};
21
+ }
22
+ const issue = copyIssue(answer, input);
23
+ if (issue !== undefined) {
24
+ return { issue };
25
+ }
26
+ const { title, body } = answer;
27
+ return { copy: { title, body } };
28
+ }
29
+ /**
30
+ * Resolves one topic for several locales, asking `resolver` once per
31
+ * distinct locale (an absent locale counts as one): a bulk send groups its
32
+ * recipients by locale and sends each group the copy resolved for it. The
33
+ * map is keyed by the locales as given.
34
+ */
35
+ export async function resolveNotificationCopyByLocale(resolver, input) {
36
+ const { locales, ...shared } = input;
37
+ const resolved = new Map();
38
+ for (const locale of locales) {
39
+ if (!resolved.has(locale)) {
40
+ resolved.set(locale, await resolveNotificationCopy(resolver, {
41
+ ...shared,
42
+ ...(locale === undefined ? {} : { locale }),
43
+ }));
44
+ }
45
+ }
46
+ return resolved;
47
+ }
48
+ function copyIssue(answer, input) {
49
+ if (typeof answer !== 'object' || answer === null || Array.isArray(answer)) {
50
+ return 'the copy resolver answered something other than a title and a body';
51
+ }
52
+ const { title, body } = answer;
53
+ if (typeof title !== 'string' || typeof body !== 'string') {
54
+ return 'the copy resolver answered no string title and body';
55
+ }
56
+ if (title.trim().length === 0 || body.trim().length === 0) {
57
+ return 'the copy resolver answered a blank title or body';
58
+ }
59
+ if (input.notice !== undefined) {
60
+ // Every notice variant follows the same text rules (`notificationNoticeCopyIssues`
61
+ // applies them to each one); the path names the answer as notices.copy would.
62
+ const issues = notificationNoticeTextIssues({ title, body }, `notices.copy[${JSON.stringify(input.locale ?? input.defaultLocale)}][${JSON.stringify(input.notice.variant)}]`);
63
+ if (issues.length > 0) {
64
+ return `the copy resolver's notice text breaks the notice rules: ${issues
65
+ .map((issue) => `${issue.path}: ${issue.message}`)
66
+ .join('; ')}`;
67
+ }
68
+ }
69
+ return undefined;
70
+ }
@@ -89,10 +89,17 @@ export interface NotificationRemoteDeliveryRequest {
89
89
  * answer). The claim is kept until its lease ends.
90
90
  *
91
91
  * A provider that throws is treated as `ambiguous`.
92
+ *
93
+ * A batch may also answer `not-sent` for a request it made no call for (its
94
+ * calls ran past `startWithinMs`): the row goes back as it was, due again
95
+ * shortly, without a rate-limit backoff or the 24-hour give-up, since no
96
+ * provider refused anything.
92
97
  */
93
98
  export type NotificationRemoteDeliveryAnswer = {
94
99
  readonly status: 'accepted';
95
100
  readonly providerMessageId?: string;
101
+ } | {
102
+ readonly status: 'not-sent';
96
103
  } | {
97
104
  readonly status: 'unregistered';
98
105
  } | {
@@ -106,8 +113,69 @@ export type NotificationRemoteDeliveryAnswer = {
106
113
  } | {
107
114
  readonly status: 'ambiguous';
108
115
  readonly providerCode?: string;
116
+ /**
117
+ * A bulk call that reached some of its recipients and did not say which
118
+ * (BK1). A replay would reach those recipients again, so the delivery is
119
+ * not replayed: it stays in flight until its key's retention ends, and
120
+ * the backend reports it. Only a batch answer sets it.
121
+ */
122
+ readonly unattributed?: true;
109
123
  };
110
124
  /**
125
+ * A provider's bulk path (RFC open question 5, BK1): one provider call for
126
+ * several recipients of one topic, each with its own ledger key and answer.
127
+ * A provider without it sends one recipient per call.
128
+ */
129
+ export interface NotificationRemoteBulkCapability {
130
+ /** The most recipients one `sendBatch` call takes. */
131
+ readonly maxRecipients: number;
132
+ /**
133
+ * The fewest recipients the bulk API is meant for; a smaller batch goes
134
+ * one recipient per `send` call. 2 by default.
135
+ */
136
+ readonly minRecipients?: number;
137
+ /**
138
+ * Whether the provider's answer names each recipient. `false` means it
139
+ * answers the batch as a whole (counts only): the provider then gives
140
+ * every recipient the same answer, `accepted` only when the counts prove
141
+ * each one, and `ambiguous` with `unattributed` when they prove only that
142
+ * some were reached.
143
+ */
144
+ readonly perRecipientOutcomes: boolean;
145
+ /**
146
+ * A daily window, in a time zone, in which the host holds bulk messages
147
+ * until it ends (Apps in Toss holds `send-bulk-message` from 21:00 to
148
+ * 08:00). The service sends one recipient per call inside it, so the
149
+ * gate's timing stands.
150
+ */
151
+ /**
152
+ * The provider's call group of a request: requests with different keys
153
+ * never share a call (Devvit takes one title and body a call). The
154
+ * service makes one `sendBatch` per group and runs the gate again for
155
+ * each request just before its group's call, so a later call never sends
156
+ * on a stale gate check. One group when absent.
157
+ */
158
+ callKey?(request: NotificationRemoteDeliveryRequest): string;
159
+ readonly holdWindow?: {
160
+ /** `HH:MM`, inclusive. */
161
+ readonly start: string;
162
+ /** `HH:MM`, exclusive; earlier than `start` when the window spans midnight. */
163
+ readonly end: string;
164
+ /** An IANA time zone. */
165
+ readonly timeZone: string;
166
+ };
167
+ }
168
+ /** How long a provider may keep starting calls for one `sendBatch`. */
169
+ export interface NotificationRemoteBatchOptions {
170
+ /**
171
+ * Milliseconds from the start of `sendBatch` after which the provider
172
+ * starts no further call (one batch may take several, such as one per
173
+ * text): the requests' claims must outlive the calls. A request it did not
174
+ * start answers `not-sent`.
175
+ */
176
+ readonly startWithinMs?: number;
177
+ }
178
+ /**
111
179
  * The provider code of a `rejected` answer for a message that would arrive
112
180
  * after its expiry: nothing was sent, and it is routine, not a setup error.
113
181
  */
@@ -117,6 +185,17 @@ export interface NotificationRemoteDeliveryProvider {
117
185
  /** Equals the registry entry's `provider`. */
118
186
  readonly id: NotificationProviderId;
119
187
  send(request: NotificationRemoteDeliveryRequest): Promise<NotificationRemoteDeliveryAnswer>;
188
+ /** The bulk path, declared with `sendBatch`; without it every recipient is one `send`. */
189
+ readonly bulk?: NotificationRemoteBulkCapability;
190
+ /**
191
+ * One provider call for `requests` (at most `bulk.maxRecipients`, each a
192
+ * different recipient of the same send kind): the answers in the order of
193
+ * the requests, one per request. The service runs the gate for each
194
+ * request before the call and settles each answer on that request's own
195
+ * ledger key; an answer that is missing or not one of the five kinds is
196
+ * `ambiguous`, as a throw is for every request.
197
+ */
198
+ sendBatch?(requests: readonly NotificationRemoteDeliveryRequest[], options?: NotificationRemoteBatchOptions): Promise<readonly NotificationRemoteDeliveryAnswer[]>;
120
199
  /**
121
200
  * The host's permission at send time, for a provider whose registry entry
122
201
  * is `serverReadable` (Devvit `isOptedIn`). The gate requires it there.
@@ -3,8 +3,11 @@
3
3
  * "Server"): the provider registry, the endpoint registry, consent records
4
4
  * and the notices API, sends through the policy gate, server reminders and
5
5
  * the scheduler (consent notice pushes, paced due rows), and the remote
6
- * delivery provider contract, with the FCM provider.
6
+ * delivery provider contract, with the FCM provider, and the copy resolver
7
+ * port that chooses a send's localized title and body.
7
8
  */
9
+ export * from './bulk.js';
10
+ export * from './copy.js';
8
11
  export * from './delivery-provider.js';
9
12
  export * from './fcm.js';
10
13
  export * from './registry.js';
@@ -3,8 +3,11 @@
3
3
  * "Server"): the provider registry, the endpoint registry, consent records
4
4
  * and the notices API, sends through the policy gate, server reminders and
5
5
  * the scheduler (consent notice pushes, paced due rows), and the remote
6
- * delivery provider contract, with the FCM provider.
6
+ * delivery provider contract, with the FCM provider, and the copy resolver
7
+ * port that chooses a send's localized title and body.
7
8
  */
9
+ export * from './bulk.js';
10
+ export * from './copy.js';
8
11
  export * from './delivery-provider.js';
9
12
  export * from './fcm.js';
10
13
  export * from './registry.js';
@@ -1,5 +1,6 @@
1
1
  import { assertNotificationTopicCatalog, resolveNotificationTopicsForTarget, } from '@mpgd/catalog';
2
2
  import { assertNotificationDeliveryProviderEntry, assertNotificationDeployment, referenceNotificationTargetProviders, } from '@mpgd/platform/notifications';
3
+ import { assertNotificationBulkCapability } from './bulk.js';
3
4
  import { describeError as describe } from './text.js';
4
5
  /**
5
6
  * The server-side provider registry (notifications v2 RFC, "Provider
@@ -50,6 +51,13 @@ export function createNotificationProviderRegistry(registrations) {
50
51
  if (entry.permission.serverReadable && registration.delivery.readPermission === undefined) {
51
52
  throw new TypeError(`${at}: provider ${entry.provider} is server-readable but its delivery provider has no readPermission.`);
52
53
  }
54
+ const { bulk, sendBatch } = registration.delivery;
55
+ if ((bulk === undefined) !== (sendBatch === undefined)) {
56
+ throw new TypeError(`${at}: provider ${entry.provider} declares a bulk capability and sendBatch together, or neither.`);
57
+ }
58
+ if (bulk !== undefined) {
59
+ assertNotificationBulkCapability(bulk, at);
60
+ }
53
61
  for (const limit of entry.limits ?? []) {
54
62
  if (limit.windowSeconds > maxHistoryWindowSeconds) {
55
63
  throw new TypeError(`${at}: a limit window of ${limit.windowSeconds} seconds is longer than the ${notificationHistoryRetentionDays}-day delivery history.`);
@@ -1,4 +1,6 @@
1
1
  import { NotificationError, type NotificationConsentRecord, type NotificationDeliveryOutcome, type NotificationDeployment, type NotificationNotice, type NotificationTemplateData } from '@mpgd/platform/notifications';
2
+ import { type NotificationBulkDeliverOptions, type NotificationBulkDeliveryResult } from './bulk.js';
3
+ import { type NotificationCopyResolver } from './copy.js';
2
4
  import { type NotificationProviderRegistry, type NotificationProviderRegistrySnapshot, type NotificationServiceCatalog } from './registry.js';
3
5
  import { type NotificationNoticeChannel, type NotificationPurgeCounts, type NotificationStore } from './store.js';
4
6
  /**
@@ -70,6 +72,17 @@ export interface CreateNotificationServiceInput {
70
72
  readonly player: string;
71
73
  }) => string | undefined | Promise<string | undefined>;
72
74
  /**
75
+ * Chooses a send's title and body per recipient locale where the content
76
+ * comes from copy (not a mapping's own text or a host template): reminders,
77
+ * consent notices, and direct sends alike. It replaces the default,
78
+ * `resolveCatalogNotificationCopy` (the catalog's `copy`, falling back to
79
+ * the language and then `defaultLocale`); a resolver that wants that
80
+ * fallback calls it. No copy skips the recipient as `unavailable:
81
+ * copy-missing`, and a resolver that throws is retried as a host that
82
+ * cannot answer is.
83
+ */
84
+ readonly resolveCopy?: NotificationCopyResolver;
85
+ /**
73
86
  * Runs dispatch jobs later, so a fan-out never exceeds one invocation's
74
87
  * subrequest limit. Without it every job runs inline before `deliver`
75
88
  * answers.
@@ -159,6 +172,25 @@ export interface NotificationService {
159
172
  }): Promise<void>;
160
173
  /** One send through the ledger, the gate, and the providers. */
161
174
  deliver(input: unknown): Promise<NotificationDeliveryOutcome>;
175
+ /**
176
+ * A bulk send (RFC open question 5): one topic to many players through one
177
+ * provider, each recipient a single send with its own key
178
+ * (`<idempotencyKey>:<encodeURIComponent(player)>`), ledger row, and gate
179
+ * check, made immediately before its batch's provider call. Recipients the
180
+ * gate refuses never reach the provider. A provider with a bulk capability
181
+ * gets one `sendBatch` call per batch of allowed recipients (one `send`
182
+ * each below its `minRecipients` or inside its hold window); any other
183
+ * provider gets one `send` per recipient. The same call retried answers
184
+ * recipients already sent from the ledger and sends only the rest.
185
+ *
186
+ * It is an operator action from the game's server, like `deliver`: never
187
+ * serve it to a client session. `options` are the host's bounds: no batch
188
+ * starts after `budgetMs`, and recipients past `maxRecipients` attempts, or
189
+ * past the provider's `rate` budget, answer `not-attempted`. A recipient
190
+ * answered from the ledger is no attempt, though its key is still read:
191
+ * resend only the `not-attempted` recipients to keep a retry small.
192
+ */
193
+ deliverBulk(input: unknown, options?: NotificationBulkDeliverOptions): Promise<NotificationBulkDeliveryResult>;
162
194
  /** Runs one queued dispatch job; a job that is no longer due does nothing. */
163
195
  dispatch(job: NotificationDispatchJob): Promise<void>;
164
196
  /**