@mpgd/game-services 0.24.1 → 0.25.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/dist/notifications/ait-functional-delivery.d.ts +165 -0
- package/dist/notifications/ait-functional-delivery.js +445 -0
- package/dist/notifications/delivery-provider.d.ts +121 -0
- package/dist/notifications/delivery-provider.js +23 -0
- package/dist/notifications/fcm.d.ts +138 -0
- package/dist/notifications/fcm.js +602 -0
- package/dist/notifications/index.d.ts +12 -0
- package/dist/notifications/index.js +12 -0
- package/dist/notifications/registry.d.ts +86 -0
- package/dist/notifications/registry.js +131 -0
- package/dist/notifications/service.d.ts +222 -0
- package/dist/notifications/service.js +2609 -0
- package/dist/notifications/store-conformance.d.ts +11 -0
- package/dist/notifications/store-conformance.js +795 -0
- package/dist/notifications/store.d.ts +423 -0
- package/dist/notifications/store.js +443 -0
- package/dist/notifications/text.d.ts +2 -0
- package/dist/notifications/text.js +4 -0
- package/package.json +17 -5
|
@@ -0,0 +1,2609 @@
|
|
|
1
|
+
import { assertNotificationConsentRecord, assertNotificationDeployment, assertNotificationEndpointRegistration, assertRecordNotificationNoticeInput, createNotificationNoticeIdempotencyKey, createNotificationReminderIdempotencyKey, evaluateNotificationDelivery, evaluateNotificationReminderConsent, isNotificationTimestamp, isSameNotificationNotice, isValidNotificationTimeZone, latestNotificationConsent, listNotificationNotices, normalizeNotificationTimestamp, NotificationError, notificationKeyIssue, notificationNoticeCopyIssues, notificationNoticeSendFrom, notificationNoticeTopic, notificationProviderCarriesNotices, notificationReservedKeyPrefix, notificationSendFingerprint, notificationTemplateDataIssue, toSeoulCalendarDate, } from '@mpgd/platform/notifications';
|
|
2
|
+
import { fcmInstallationIdRefusal, notificationExpiredProviderCode, } from './delivery-provider.js';
|
|
3
|
+
import { notificationHistoryRetentionDays, } from './registry.js';
|
|
4
|
+
import { bestNotificationOutcome, formatNotificationInstant, isNotificationRowAbandoned, isNotificationRowDue, notificationAmbiguousReplayHours, notificationSendOutcome, } from './store.js';
|
|
5
|
+
import { describeError as describe } from './text.js';
|
|
6
|
+
/**
|
|
7
|
+
* The notifications v2 server (RFC "Server"): consent records and the notices
|
|
8
|
+
* API, the endpoint registry bound to the game-services session, and sends
|
|
9
|
+
* through the idempotency ledger, the pure policy gate
|
|
10
|
+
* (`evaluateNotificationDelivery` from `@mpgd/platform/notifications`), and
|
|
11
|
+
* the delivery providers.
|
|
12
|
+
*
|
|
13
|
+
* - **Sessions.** Every player call names the authenticated session; the
|
|
14
|
+
* installation id is the session's, never a request field.
|
|
15
|
+
* - **Sends.** A send is validated, bound to its fingerprint, and answered
|
|
16
|
+
* from its key state. A new key resolves its recipients (every usable
|
|
17
|
+
* endpoint of a device provider, or the platform identity), checks the gate
|
|
18
|
+
* once for each, and stores one row per recipient the gate allows or
|
|
19
|
+
* defers. Each allowed row is then a dispatch job: inline, or through the
|
|
20
|
+
* queue, and the gate runs again when the job makes the provider call.
|
|
21
|
+
* - **Answers.** `accepted` delivers, `unregistered` invalidates the
|
|
22
|
+
* endpoint, `rate-limited` defers with backoff for at most 24 hours,
|
|
23
|
+
* `rejected` is not stored (with `agreement` it records a provider denial),
|
|
24
|
+
* and `ambiguous` keeps the claim for the lease.
|
|
25
|
+
* - **Replays.** `dispatchDue` replays rows that came due: deferred rows, and
|
|
26
|
+
* every in-flight row whose lease ended (a queued job that was lost, a
|
|
27
|
+
* holder that failed before the call, an ambiguous answer). Ambiguous
|
|
28
|
+
* replays stop a day after the first attempt. A direct caller may also
|
|
29
|
+
* replay its own platform rows by sending the key again after the lease.
|
|
30
|
+
* Due rows go out at no more than each provider's registry `rate`, through
|
|
31
|
+
* the queue when one is bound and inline otherwise.
|
|
32
|
+
* - **Scheduler.** `runScheduler` turns due server reminders into
|
|
33
|
+
* `reminder:` sends and, under the Korean rules, pushes each consent notice
|
|
34
|
+
* nobody recorded by seven days before its `dueBy` as a `notice:` send of
|
|
35
|
+
* the catalog's notice topic; both then leave as due rows. A refused
|
|
36
|
+
* notice push backs off, doubling from an hour to a day.
|
|
37
|
+
* - **Consent history.** Each write compacts the player's history: records
|
|
38
|
+
* no rule reads any more are deleted a day after they were written.
|
|
39
|
+
*/
|
|
40
|
+
const secondMs = 1000;
|
|
41
|
+
const dayMs = 86_400 * secondMs;
|
|
42
|
+
const keyRetentionMs = 30 * dayMs;
|
|
43
|
+
const endpointStaleMs = 60 * dayMs;
|
|
44
|
+
const endpointPurgeDelayMs = 30 * dayMs;
|
|
45
|
+
const historyRetentionMs = notificationHistoryRetentionDays * dayMs;
|
|
46
|
+
const rateLimitGiveUpMs = dayMs;
|
|
47
|
+
const defaultRetryAfterSeconds = 60;
|
|
48
|
+
const defaultClaimLeaseSeconds = 300;
|
|
49
|
+
/** Conditional writes retried before a call answers that the ledger is busy. */
|
|
50
|
+
const maxLedgerAttempts = 8;
|
|
51
|
+
/** Settling an answer the provider already gave is retried harder, so it is not replayed. */
|
|
52
|
+
const maxCompleteAttempts = 24;
|
|
53
|
+
/** How long after the first attempt an ambiguous delivery is replayed. */
|
|
54
|
+
const ambiguousReplayMs = notificationAmbiguousReplayHours * 3_600_000;
|
|
55
|
+
/** The longest wait between ambiguous replays. */
|
|
56
|
+
const maxAmbiguousLeaseMs = 3_600_000;
|
|
57
|
+
/** Longest accepted text for ids, locales, links, and versions. */
|
|
58
|
+
const maxTextLength = 2048;
|
|
59
|
+
/** Longest accepted device address value. */
|
|
60
|
+
const maxAddressLength = 4096;
|
|
61
|
+
/** Longest row key: a send key, a colon, and an installation id that percent-encoding can grow ninefold. */
|
|
62
|
+
const maxRowKeyLength = maxTextLength + 1 + 9 * maxTextLength;
|
|
63
|
+
/** Largest consent evidence envelope, as JSON. */
|
|
64
|
+
const maxEvidenceBytes = 16_384;
|
|
65
|
+
/** How long a scheduler item waits after a failure that may pass. */
|
|
66
|
+
const schedulerRetryMs = 60 * secondMs;
|
|
67
|
+
/** How often a notice check looks again while a push of it waits in the ledger. */
|
|
68
|
+
const noticeRecheckMs = 3_600_000;
|
|
69
|
+
/** The first wait after a refused notice push; it doubles, up to a day. */
|
|
70
|
+
const noticeBackoffMs = 3_600_000;
|
|
71
|
+
/** Records no rule reads are kept this long, for the daily cap, and then deleted. */
|
|
72
|
+
const consentCompactionMs = dayMs;
|
|
73
|
+
/** Lists every notice still owed, now or later: the next re-confirmation of each consent. */
|
|
74
|
+
const noticeHorizon = '9999-12-31T00:00:00Z';
|
|
75
|
+
const noticesEntryId = 'notices';
|
|
76
|
+
/** How much wall time one scheduler run may use by default. */
|
|
77
|
+
const defaultSchedulerBudgetMs = 20_000;
|
|
78
|
+
const reminderEntryPrefix = 'reminder:';
|
|
79
|
+
const reminderEntryId = (key) => `${reminderEntryPrefix}${key}`;
|
|
80
|
+
/** How far through the gate a set-time refusal got: the furthest step is the one answered. */
|
|
81
|
+
const reminderRefusalRank = {
|
|
82
|
+
'permission-required': 1,
|
|
83
|
+
'request-missing': 2,
|
|
84
|
+
'preference-off': 2,
|
|
85
|
+
'advertising-consent-missing': 3,
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Client-written consent records a player may add per rolling day. It is
|
|
89
|
+
* generous: it only stops a client that writes in a loop, since every send
|
|
90
|
+
* reads the player's whole history. It is checked before the write, so
|
|
91
|
+
* concurrent writes may pass it by a few.
|
|
92
|
+
*/
|
|
93
|
+
export const maxClientNotificationConsentRecordsPerDay = 200;
|
|
94
|
+
/**
|
|
95
|
+
* Pending server reminders a player may keep, as many as iOS keeps local
|
|
96
|
+
* notifications. Replacing a pending one is always allowed; a new key past
|
|
97
|
+
* it answers `capacity-exceeded`. The count is read before the write, so
|
|
98
|
+
* sets that race may pass it by a few, as the daily consent cap may.
|
|
99
|
+
*/
|
|
100
|
+
export const maxPendingNotificationReminders = 64;
|
|
101
|
+
/** How far ahead a server reminder may be set; a later `fireAt` is refused. */
|
|
102
|
+
export const notificationReminderHorizonDays = 90;
|
|
103
|
+
/**
|
|
104
|
+
* A player wrote more consent records than one rolling day allows. It is
|
|
105
|
+
* retryable after `retryAfterSeconds`, when the oldest counted record leaves
|
|
106
|
+
* the window.
|
|
107
|
+
*/
|
|
108
|
+
export class NotificationConsentRateLimitError extends NotificationError {
|
|
109
|
+
retryAfterSeconds;
|
|
110
|
+
constructor(retryAfterSeconds) {
|
|
111
|
+
super('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
|
|
112
|
+
message: `Too many consent records today; retry in ${retryAfterSeconds} s.`,
|
|
113
|
+
});
|
|
114
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** A gate caller error (such as missing template data): the send cannot be made as asked. */
|
|
118
|
+
class NotificationSendInputError extends Error {
|
|
119
|
+
name = 'NotificationSendInputError';
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The provider accepted or may have sent, but storing its answer failed. It
|
|
123
|
+
* is retryable for the caller, who answers from the ledger by the same key;
|
|
124
|
+
* the row itself recovers through the due replay once its lease ends, since
|
|
125
|
+
* a queue job never replays an attempted row.
|
|
126
|
+
*/
|
|
127
|
+
class NotificationSettleError extends NotificationError {
|
|
128
|
+
constructor(options) {
|
|
129
|
+
super('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
|
|
130
|
+
message: 'The provider answered, but the answer could not be stored; the send key answers from the ledger.',
|
|
131
|
+
cause: options.cause,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export function createNotificationService(input) {
|
|
136
|
+
const { store, registry, catalog } = input;
|
|
137
|
+
const deployment = input.deployment === undefined
|
|
138
|
+
? undefined
|
|
139
|
+
: assertNotificationDeployment(input.deployment);
|
|
140
|
+
const rules = deployment?.advertisingRules ?? 'none';
|
|
141
|
+
const leaseSeconds = input.claimLeaseSeconds ?? defaultClaimLeaseSeconds;
|
|
142
|
+
// Checked in seconds: 0.5 is a whole number of milliseconds but no lease.
|
|
143
|
+
if (!Number.isInteger(leaseSeconds) || leaseSeconds <= 0 || leaseSeconds * secondMs > dayMs) {
|
|
144
|
+
throw new TypeError('claimLeaseSeconds must be a whole number of seconds up to one day.');
|
|
145
|
+
}
|
|
146
|
+
const leaseMs = leaseSeconds * secondMs;
|
|
147
|
+
/** The claim lease after `attempts` ambiguous attempts: doubling, up to an hour (or the lease, if longer). */
|
|
148
|
+
const ambiguousLeaseMs = (attempts) => Math.min(leaseMs * 2 ** Math.min(attempts, 16), Math.max(leaseMs, maxAmbiguousLeaseMs));
|
|
149
|
+
const random = input.random ?? Math.random;
|
|
150
|
+
const noticeTopic = catalog.notices?.topic;
|
|
151
|
+
if (catalog.notices !== undefined) {
|
|
152
|
+
const issues = notificationNoticeCopyIssues(catalog.notices.copy, catalog.defaultLocale);
|
|
153
|
+
if (typeof noticeTopic !== 'string' || noticeTopic.length === 0 || issues.length > 0) {
|
|
154
|
+
throw new TypeError(`The catalog's notices block needs its topic and copy by the catalog rules: ${issues
|
|
155
|
+
.map((issue) => `${issue.path}: ${issue.message}`)
|
|
156
|
+
.join('; ')}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const longestWindowMs = Math.max(dayMs, ...registry.providers.flatMap((provider) => (provider.entry.limits ?? []).map((limit) => limit.windowSeconds * secondMs)));
|
|
160
|
+
const nowMs = () => {
|
|
161
|
+
const at = (input.now ?? (() => new Date().toISOString()))();
|
|
162
|
+
return Date.parse(normalizeNotificationTimestamp(at));
|
|
163
|
+
};
|
|
164
|
+
const report = (error, operation, context = {
|
|
165
|
+
sendKey: '',
|
|
166
|
+
}, fromProvider = false) => {
|
|
167
|
+
const where = typeof context === 'string' ? { sendKey: context } : context;
|
|
168
|
+
try {
|
|
169
|
+
input.onError?.(error, {
|
|
170
|
+
operation,
|
|
171
|
+
...(where.sendKey === '' ? {} : { sendKey: where.sendKey }),
|
|
172
|
+
...(where.provider === undefined ? {} : { provider: where.provider }),
|
|
173
|
+
...(where.rowKey === undefined ? {} : { rowKey: where.rowKey }),
|
|
174
|
+
...(fromProvider ? { fromProvider } : {}),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// A failing error hook must not change the call's answer.
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
// Topics.
|
|
182
|
+
/** The topic as declared for the recipient's target, else for the provider's first target that declares it. */
|
|
183
|
+
const topicFor = (provider, target, topicId, origin) => {
|
|
184
|
+
const targets = target !== undefined && provider.targets.includes(target)
|
|
185
|
+
? [target]
|
|
186
|
+
: provider.targets;
|
|
187
|
+
for (const candidate of targets) {
|
|
188
|
+
const topic = catalog.topicsFor(candidate).find((declared) => declared.id === topicId);
|
|
189
|
+
if (topic !== undefined) {
|
|
190
|
+
// A notice says only its variant of notices.copy.
|
|
191
|
+
return origin.kind === 'notice' && catalog.notices !== undefined
|
|
192
|
+
? notificationNoticeTopic(topic, catalog.notices.copy, origin.variant)
|
|
193
|
+
: topic;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
};
|
|
198
|
+
const refuseNoticeTopic = (topic) => {
|
|
199
|
+
if (noticeTopic !== undefined && topic === noticeTopic) {
|
|
200
|
+
throw invalidInput(`Topic ${topic} is reserved for consent notices, which only the scheduler sends.`);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
// Notices.
|
|
204
|
+
const noticesOf = async (player, at) => rules === 'kr'
|
|
205
|
+
? listNotificationNotices(await store.listConsent(player), await store.listNoticesGiven(player), formatNotificationInstant(at))
|
|
206
|
+
: [];
|
|
207
|
+
const noticeListed = async (player, notice, at) => (await noticesOf(player, at)).some((listed) => isSameNotificationNotice(listed, notice));
|
|
208
|
+
// Recipients (gate step 2).
|
|
209
|
+
const usable = (endpoint, at) => endpoint.status === 'valid'
|
|
210
|
+
&& at - Date.parse(endpoint.lastRegisteredAt) < endpointStaleMs
|
|
211
|
+
&& (endpoint.sessionExpiresAt === undefined || Date.parse(endpoint.sessionExpiresAt) > at);
|
|
212
|
+
const deviceRecipient = (provider, sendKey, endpoint) => ({
|
|
213
|
+
provider,
|
|
214
|
+
rowKey: `${sendKey}:${encodeURIComponent(endpoint.installationId)}`,
|
|
215
|
+
installationId: endpoint.installationId,
|
|
216
|
+
endpoint,
|
|
217
|
+
resolved: true,
|
|
218
|
+
...(endpoint.target === undefined ? {} : { target: endpoint.target }),
|
|
219
|
+
...(endpoint.locale === undefined ? {} : { locale: endpoint.locale }),
|
|
220
|
+
...(endpoint.timeZone === undefined ? {} : { timeZone: endpoint.timeZone }),
|
|
221
|
+
});
|
|
222
|
+
const platformRecipient = async (provider, request) => {
|
|
223
|
+
let id;
|
|
224
|
+
try {
|
|
225
|
+
id = await input.resolvePlatformRecipient?.({
|
|
226
|
+
provider: provider.entry.provider,
|
|
227
|
+
player: request.player,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
// An identity service that cannot answer now is retried, as a host read is.
|
|
232
|
+
throw new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
|
|
233
|
+
message: `The platform recipient of provider ${provider.entry.provider} could not be resolved.`,
|
|
234
|
+
cause: error,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const registration = await store.getPlatformRegistration({
|
|
238
|
+
player: request.player,
|
|
239
|
+
provider: provider.entry.provider,
|
|
240
|
+
});
|
|
241
|
+
const platformId = typeof id === 'string' && id.length > 0 ? id : undefined;
|
|
242
|
+
return {
|
|
243
|
+
provider,
|
|
244
|
+
rowKey: request.idempotencyKey,
|
|
245
|
+
...(platformId === undefined ? {} : { platformId }),
|
|
246
|
+
resolved: platformId !== undefined,
|
|
247
|
+
...(registration?.locale === undefined ? {} : { locale: registration.locale }),
|
|
248
|
+
...(registration?.timeZone === undefined ? {} : { timeZone: registration.timeZone }),
|
|
249
|
+
};
|
|
250
|
+
};
|
|
251
|
+
/**
|
|
252
|
+
* Every recipient of a send: one per usable endpoint (installation-id
|
|
253
|
+
* order) of each device provider, the platform identity of each
|
|
254
|
+
* platform-user provider, over the named provider or every provider. When
|
|
255
|
+
* no provider has a recipient, one unresolved recipient per provider, so
|
|
256
|
+
* the gate still answers its earlier steps (`topic-unknown` before
|
|
257
|
+
* `no-endpoint`). A consent notice goes only through providers that can
|
|
258
|
+
* carry it (`notificationProviderCarriesNotices`).
|
|
259
|
+
*/
|
|
260
|
+
const recipientsOf = async (request, at, origin) => {
|
|
261
|
+
let scope = registry.providers;
|
|
262
|
+
if (request.provider !== undefined) {
|
|
263
|
+
const named = registry.provider(request.provider);
|
|
264
|
+
scope = named === undefined ? [] : [named];
|
|
265
|
+
}
|
|
266
|
+
if (origin.kind === 'notice') {
|
|
267
|
+
scope = scope.filter((provider) => notificationProviderCarriesNotices(provider.entry));
|
|
268
|
+
}
|
|
269
|
+
// Providers are independent: resolve them together, then keep the order.
|
|
270
|
+
const perProvider = await Promise.all(scope.map(async (provider) => {
|
|
271
|
+
if (provider.entry.addressing !== 'device') {
|
|
272
|
+
return [await platformRecipient(provider, request)];
|
|
273
|
+
}
|
|
274
|
+
const endpoints = (await store.listEndpoints({
|
|
275
|
+
player: request.player,
|
|
276
|
+
provider: provider.entry.provider,
|
|
277
|
+
}))
|
|
278
|
+
.filter((endpoint) => usable(endpoint, at))
|
|
279
|
+
.sort((left, right) => compareText(left.installationId, right.installationId));
|
|
280
|
+
return endpoints.length === 0
|
|
281
|
+
? [{ provider, rowKey: request.idempotencyKey, resolved: false }]
|
|
282
|
+
: endpoints.map((endpoint) => deviceRecipient(provider, request.idempotencyKey, endpoint));
|
|
283
|
+
}));
|
|
284
|
+
const resolved = perProvider.flat().filter((recipient) => recipient.resolved);
|
|
285
|
+
const unresolved = perProvider.flat().filter((recipient) => !recipient.resolved);
|
|
286
|
+
return resolved.length > 0 ? resolved : unresolved;
|
|
287
|
+
};
|
|
288
|
+
/** A stored row's recipient on replay: its endpoint now, unresolved once it is gone or moved. */
|
|
289
|
+
const recipientOfRow = async (provider, request, row, at) => {
|
|
290
|
+
if (row.installationId === undefined) {
|
|
291
|
+
return platformRecipient(provider, request);
|
|
292
|
+
}
|
|
293
|
+
const endpoint = await store.getEndpoint({
|
|
294
|
+
provider: provider.entry.provider,
|
|
295
|
+
installationId: row.installationId,
|
|
296
|
+
});
|
|
297
|
+
return endpoint !== undefined && endpoint.player === request.player && usable(endpoint, at)
|
|
298
|
+
? deviceRecipient(provider, request.idempotencyKey, endpoint)
|
|
299
|
+
: { provider, rowKey: row.key, installationId: row.installationId, resolved: false };
|
|
300
|
+
};
|
|
301
|
+
// Gate inputs.
|
|
302
|
+
/**
|
|
303
|
+
* The sends the gate counts for a recipient on `provider`, oldest first,
|
|
304
|
+
* within the longest limit window: delivered sends, and sends another
|
|
305
|
+
* holder is sending or may have sent (an attempted in-flight row). Each
|
|
306
|
+
* send counts once, on `provider` when it reached it there, so one send
|
|
307
|
+
* delivered through two providers is one send for the topic's cap. The
|
|
308
|
+
* send being made is left out, including its earlier delivery to another
|
|
309
|
+
* endpoint, and so is an in-flight send claimed after `self`: of two sends
|
|
310
|
+
* claimed together, the earlier one (then the lower key) is the one the
|
|
311
|
+
* other counts, so a cap of one still lets one through. A claim counts
|
|
312
|
+
* from the moment it is taken, before its own gate has run: a send the
|
|
313
|
+
* gate then holds can defer another for a while, which is the price of
|
|
314
|
+
* never letting two through a cap of one.
|
|
315
|
+
*/
|
|
316
|
+
const loadHistory = async (player, excluding, at, self) => {
|
|
317
|
+
const bySend = new Map();
|
|
318
|
+
const add = (sendKey, entry) => {
|
|
319
|
+
bySend.set(sendKey, [...(bySend.get(sendKey) ?? []), entry]);
|
|
320
|
+
};
|
|
321
|
+
for (const sent of await store.listHistory({
|
|
322
|
+
player,
|
|
323
|
+
since: formatNotificationInstant(at - longestWindowMs),
|
|
324
|
+
})) {
|
|
325
|
+
if (sent.sendKey !== excluding) {
|
|
326
|
+
add(sent.sendKey, { provider: sent.provider, topic: sent.topic, at: sent.at });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
for (const send of await store.listInFlightSends(player)) {
|
|
330
|
+
if (send.key === excluding) {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
for (const row of send.rows) {
|
|
334
|
+
if (row.state.status !== 'in-flight' || !row.state.attempted) {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const claimedAt = Date.parse(row.state.attemptedAt ?? send.changedAt);
|
|
338
|
+
if (self !== undefined
|
|
339
|
+
&& (claimedAt > self.claimedAt || (claimedAt === self.claimedAt && send.key > self.key))) {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
add(send.key, {
|
|
343
|
+
provider: row.provider,
|
|
344
|
+
topic: send.request.topic,
|
|
345
|
+
at: formatNotificationInstant(claimedAt),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return bySend;
|
|
350
|
+
};
|
|
351
|
+
const readServerPermission = async (provider, request) => {
|
|
352
|
+
if (!provider.entry.permission.serverReadable) {
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const state = await provider.delivery.readPermission?.({
|
|
357
|
+
player: request.player,
|
|
358
|
+
topic: request.topic,
|
|
359
|
+
});
|
|
360
|
+
if (state === undefined) {
|
|
361
|
+
throw new TypeError(`Provider ${provider.entry.provider} answered no permission.`);
|
|
362
|
+
}
|
|
363
|
+
return state;
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
// A host that cannot answer now is retried, whatever the error's class.
|
|
367
|
+
throw new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
|
|
368
|
+
message: `The host could not answer the send-time permission read for provider ${provider.entry.provider}.`,
|
|
369
|
+
cause: error,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
/**
|
|
374
|
+
* For a provider whose permission the server reads, the gate's answer
|
|
375
|
+
* when a step before the permission step refuses (the topic, the channel,
|
|
376
|
+
* the purpose, the recipient, the link): that answer needs no host read,
|
|
377
|
+
* so a host outage cannot turn it into a retry. `evaluate` runs the gate
|
|
378
|
+
* with a stand-in permission, which only the later steps read; its history
|
|
379
|
+
* and time do not matter to the earlier ones. Any other answer, or an
|
|
380
|
+
* error, means the real read decides.
|
|
381
|
+
*/
|
|
382
|
+
const earlyRefusal = (provider, evaluate) => {
|
|
383
|
+
if (!provider.entry.permission.serverReadable) {
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
let early;
|
|
387
|
+
try {
|
|
388
|
+
early = evaluate('granted');
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
return early.decision.status !== 'allowed' && earlyGateReasons.has(early.decision.reason)
|
|
394
|
+
? early
|
|
395
|
+
: undefined;
|
|
396
|
+
};
|
|
397
|
+
const decide = (request, origin, recipient, consent, history, serverPermission, at) => {
|
|
398
|
+
const topic = topicFor(recipient.provider, recipient.target, request.topic, origin);
|
|
399
|
+
const locale = request.locale ?? recipient.locale;
|
|
400
|
+
try {
|
|
401
|
+
return {
|
|
402
|
+
topic,
|
|
403
|
+
decision: evaluateNotificationDelivery({
|
|
404
|
+
entry: recipient.provider.entry,
|
|
405
|
+
...(deployment === undefined ? {} : { deployment }),
|
|
406
|
+
topic,
|
|
407
|
+
recipientResolved: recipient.resolved,
|
|
408
|
+
...(request.link === undefined ? {} : { link: request.link }),
|
|
409
|
+
consent,
|
|
410
|
+
...(serverPermission === undefined ? {} : { serverPermission }),
|
|
411
|
+
...(recipient.endpoint === undefined ? {} : { endpointPermission: recipient.endpoint.permission }),
|
|
412
|
+
history,
|
|
413
|
+
at: formatNotificationInstant(at),
|
|
414
|
+
...(recipient.timeZone === undefined ? {} : { timeZone: recipient.timeZone }),
|
|
415
|
+
...(locale === undefined ? {} : { locale }),
|
|
416
|
+
defaultLocale: catalog.defaultLocale,
|
|
417
|
+
...(request.templateData === undefined ? {} : { templateData: request.templateData }),
|
|
418
|
+
}),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
if (error instanceof TypeError) {
|
|
423
|
+
throw new NotificationSendInputError(error.message, { cause: error });
|
|
424
|
+
}
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
// Ledger.
|
|
429
|
+
const newToken = () => globalThis.crypto.randomUUID();
|
|
430
|
+
/** A stored send, unless its retention ended (then it is forgotten). */
|
|
431
|
+
const lookup = async (key, at) => {
|
|
432
|
+
const held = await store.getSend(key);
|
|
433
|
+
if (held !== undefined && at - Date.parse(held.send.changedAt) >= keyRetentionMs) {
|
|
434
|
+
await store.updateSend({ key, version: held.version, send: undefined });
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
return held;
|
|
438
|
+
};
|
|
439
|
+
const rowsWith = (send, row, change) => send.rows.flatMap((candidate) => {
|
|
440
|
+
if (candidate.key !== row.key || candidate.provider !== row.provider) {
|
|
441
|
+
return [candidate];
|
|
442
|
+
}
|
|
443
|
+
return change.kind === 'remove' ? [] : [change.row];
|
|
444
|
+
});
|
|
445
|
+
const nextRateLimit = (prior, retryAfterSeconds, at) => {
|
|
446
|
+
const since = prior === undefined ? at : Date.parse(prior.since);
|
|
447
|
+
const deadline = since + rateLimitGiveUpMs;
|
|
448
|
+
if (at >= deadline) {
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
const answered = retryAfterSeconds !== undefined && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
|
|
452
|
+
? retryAfterSeconds
|
|
453
|
+
: undefined;
|
|
454
|
+
const delaySeconds = prior?.delaySeconds ?? answered ?? defaultRetryAfterSeconds;
|
|
455
|
+
const attempts = prior?.attempts ?? 0;
|
|
456
|
+
// The first deferral waits the provider's delay; later ones back off
|
|
457
|
+
// exponentially from it, with up to 25% jitter so retries spread out, and
|
|
458
|
+
// never wait less than the provider asks this time.
|
|
459
|
+
const draw = random();
|
|
460
|
+
const jitter = attempts === 0 || !Number.isFinite(draw)
|
|
461
|
+
? 1
|
|
462
|
+
: 1 + Math.min(Math.max(draw, 0), 0.999) * 0.25;
|
|
463
|
+
const delayMs = Math.max(delaySeconds * 2 ** attempts * secondMs * jitter, (answered ?? 0) * secondMs);
|
|
464
|
+
return {
|
|
465
|
+
until: formatNotificationInstant(Math.min(Math.ceil(at + delayMs), deadline)),
|
|
466
|
+
state: { since: formatNotificationInstant(since), delaySeconds, attempts: attempts + 1 },
|
|
467
|
+
};
|
|
468
|
+
};
|
|
469
|
+
const callProvider = async (recipient, request, topic, decision, at) => {
|
|
470
|
+
const mappedLink = topic.mapping?.['link'];
|
|
471
|
+
const link = request.link ?? (typeof mappedLink === 'string' ? mappedLink : undefined);
|
|
472
|
+
const target = recipient.endpoint !== undefined
|
|
473
|
+
? {
|
|
474
|
+
kind: 'device',
|
|
475
|
+
installationId: recipient.endpoint.installationId,
|
|
476
|
+
address: recipient.endpoint.address,
|
|
477
|
+
}
|
|
478
|
+
: { kind: 'platform-user', id: recipient.platformId ?? '' };
|
|
479
|
+
const locale = request.locale ?? recipient.locale;
|
|
480
|
+
try {
|
|
481
|
+
const answer = await recipient.provider.delivery.send({
|
|
482
|
+
idempotencyKey: recipient.rowKey,
|
|
483
|
+
sendKey: request.idempotencyKey,
|
|
484
|
+
provider: recipient.provider.entry.provider,
|
|
485
|
+
player: request.player,
|
|
486
|
+
topic,
|
|
487
|
+
recipient: target,
|
|
488
|
+
content: decision.content,
|
|
489
|
+
...(decision.expiresAt === undefined ? {} : { expiresAt: decision.expiresAt }),
|
|
490
|
+
...(request.templateData === undefined ? {} : { templateData: request.templateData }),
|
|
491
|
+
...(request.data === undefined ? {} : { data: request.data }),
|
|
492
|
+
...(link === undefined ? {} : { link }),
|
|
493
|
+
...(locale === undefined ? {} : { locale }),
|
|
494
|
+
at: formatNotificationInstant(at),
|
|
495
|
+
});
|
|
496
|
+
const known = deliveryAnswerOf(answer);
|
|
497
|
+
if (known === undefined) {
|
|
498
|
+
// A provider that broke the contract may still have sent it.
|
|
499
|
+
report(new TypeError('The provider answered no known delivery answer.'), 'provider.send', request.idempotencyKey);
|
|
500
|
+
return { status: 'ambiguous' };
|
|
501
|
+
}
|
|
502
|
+
return known;
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
// The provider may have sent it: one rule for every failure it cannot prove.
|
|
506
|
+
report(error, 'provider.send', request.idempotencyKey, true);
|
|
507
|
+
return { status: 'ambiguous' };
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
/**
|
|
511
|
+
* The rows a new send stores, after a first gate check per recipient:
|
|
512
|
+
* allowed recipients become queued rows, deferred ones deferred rows, and
|
|
513
|
+
* refusals are answered but not stored. A caller error throws before
|
|
514
|
+
* anything is stored. A queued row's lease ends a claim lease from now, or
|
|
515
|
+
* at `queuedUntil` for a scheduler item, whose rows are due at once and
|
|
516
|
+
* leave through the paced due path.
|
|
517
|
+
*/
|
|
518
|
+
const plan = async (request, origin, at, queuedUntil) => {
|
|
519
|
+
const recipients = await recipientsOf(request, at, origin);
|
|
520
|
+
if (recipients.length === 0) {
|
|
521
|
+
return { rows: [], refusals: [noEndpoint()] };
|
|
522
|
+
}
|
|
523
|
+
const consent = await store.listConsent(request.player);
|
|
524
|
+
const history = await loadHistory(request.player, request.idempotencyKey, at);
|
|
525
|
+
const perProvider = new Map();
|
|
526
|
+
const rows = [];
|
|
527
|
+
const refusals = [];
|
|
528
|
+
for (const recipient of recipients) {
|
|
529
|
+
const id = recipient.provider.entry.provider;
|
|
530
|
+
let inputs = perProvider.get(id);
|
|
531
|
+
if (inputs === undefined) {
|
|
532
|
+
inputs = { history: historyOn(history, id) };
|
|
533
|
+
perProvider.set(id, inputs);
|
|
534
|
+
}
|
|
535
|
+
const held = inputs;
|
|
536
|
+
const evaluate = (permission) => decide(request, origin, recipient, consent, held.history, permission, at);
|
|
537
|
+
// One read per provider and send, made only once a recipient needs it.
|
|
538
|
+
const { decision } = earlyRefusal(recipient.provider, evaluate)
|
|
539
|
+
?? evaluate(await (held.permission ??= readServerPermission(recipient.provider, request)));
|
|
540
|
+
const row = {
|
|
541
|
+
key: recipient.rowKey,
|
|
542
|
+
provider: id,
|
|
543
|
+
...(recipient.installationId === undefined ? {} : { installationId: recipient.installationId }),
|
|
544
|
+
};
|
|
545
|
+
if (decision.status === 'deferred') {
|
|
546
|
+
rows.push({
|
|
547
|
+
...row,
|
|
548
|
+
state: { status: 'deferred', reason: decision.reason, until: decision.until },
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
else if (decision.status === 'allowed') {
|
|
552
|
+
rows.push({
|
|
553
|
+
...row,
|
|
554
|
+
state: {
|
|
555
|
+
status: 'in-flight',
|
|
556
|
+
leaseUntil: formatNotificationInstant(queuedUntil ?? at + leaseMs),
|
|
557
|
+
attempted: false,
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
refusals.push(decision);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return { rows, refusals };
|
|
566
|
+
};
|
|
567
|
+
const eligible = (mode, row, at) => {
|
|
568
|
+
switch (mode) {
|
|
569
|
+
case 'job':
|
|
570
|
+
// A queued delivery runs once: a row no holder claims, either never
|
|
571
|
+
// sent or taken from the due rows for the queue. An attempted row
|
|
572
|
+
// keeps its claim, so it is replayed only after its lease, and never
|
|
573
|
+
// once its replays ended a day after the first attempt.
|
|
574
|
+
return row.state.status === 'in-flight'
|
|
575
|
+
&& row.state.claim === undefined
|
|
576
|
+
&& !(row.state.attempted
|
|
577
|
+
&& row.firstAttemptedAt !== undefined
|
|
578
|
+
&& at - Date.parse(row.firstAttemptedAt) >= ambiguousReplayMs);
|
|
579
|
+
case 'retry':
|
|
580
|
+
// A direct caller replays its own platform rows once their lease ended.
|
|
581
|
+
return row.state.status === 'in-flight'
|
|
582
|
+
&& row.installationId === undefined
|
|
583
|
+
&& isNotificationRowDue(row, at);
|
|
584
|
+
case 'due':
|
|
585
|
+
return isNotificationRowDue(row, at);
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
/**
|
|
589
|
+
* Applies a row change while this claim still holds the row; a stale claim
|
|
590
|
+
* changes nothing. Conditional writes that lose to another row of the same
|
|
591
|
+
* send are retried with a jittered backoff, so a fan-out's workers settle
|
|
592
|
+
* one after the other.
|
|
593
|
+
*/
|
|
594
|
+
const complete = async (sendKey, claimed, token, change, at) => {
|
|
595
|
+
for (let attempt = 0; attempt < maxCompleteAttempts; attempt += 1) {
|
|
596
|
+
if (attempt > 0) {
|
|
597
|
+
const draw = random();
|
|
598
|
+
await pause((Number.isFinite(draw) ? Math.min(Math.max(draw, 0), 1) : 0.5) * 10 * attempt);
|
|
599
|
+
}
|
|
600
|
+
const held = await store.getSend(sendKey);
|
|
601
|
+
const row = held?.send.rows.find((candidate) => candidate.key === claimed.key && candidate.provider === claimed.provider);
|
|
602
|
+
if (held === undefined
|
|
603
|
+
|| row === undefined
|
|
604
|
+
|| row.state.status !== 'in-flight'
|
|
605
|
+
|| row.state.claim !== token) {
|
|
606
|
+
// Another holder took the row after the lease: its answer stands.
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const rows = rowsWith(held.send, row, change);
|
|
610
|
+
const wasDelivered = notificationSendOutcome(held.send)?.status === 'delivered';
|
|
611
|
+
const next = rows.length === 0
|
|
612
|
+
? undefined
|
|
613
|
+
: { ...held.send, rows, changedAt: formatNotificationInstant(at) };
|
|
614
|
+
if (await store.updateSend({ key: sendKey, version: held.version, send: next })) {
|
|
615
|
+
if (!wasDelivered
|
|
616
|
+
&& next !== undefined
|
|
617
|
+
&& notificationSendOutcome(next)?.status === 'delivered'
|
|
618
|
+
&& next.origin.kind === 'notice'
|
|
619
|
+
&& await noticeListed(next.request.player, next.origin.notice, at)) {
|
|
620
|
+
// A notice the scheduler sent is recorded once it is delivered.
|
|
621
|
+
await store.addNoticeGiven({
|
|
622
|
+
...next.origin.notice,
|
|
623
|
+
player: next.request.player,
|
|
624
|
+
channel: 'push',
|
|
625
|
+
at: formatNotificationInstant(at),
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
throw busy(sendKey);
|
|
632
|
+
};
|
|
633
|
+
/**
|
|
634
|
+
* Claims one row, runs the gate for it immediately before the provider
|
|
635
|
+
* call, and stores the answer. Answers the row's refusal, if any.
|
|
636
|
+
*/
|
|
637
|
+
const runRow = async (sendKey, provider, rowKey, mode) => {
|
|
638
|
+
let claimed;
|
|
639
|
+
for (let attempt = 0; attempt < maxLedgerAttempts && claimed === undefined; attempt += 1) {
|
|
640
|
+
const at = nowMs();
|
|
641
|
+
const held = await store.getSend(sendKey);
|
|
642
|
+
const row = held?.send.rows.find((candidate) => candidate.key === rowKey && candidate.provider === provider);
|
|
643
|
+
if (held === undefined || row === undefined || !eligible(mode, row, at)) {
|
|
644
|
+
return {};
|
|
645
|
+
}
|
|
646
|
+
if (held.send.origin.kind === 'notice'
|
|
647
|
+
&& !await noticeListed(held.send.request.player, held.send.origin.notice, at)) {
|
|
648
|
+
// The game gave the notice while the push waited: it is not sent again.
|
|
649
|
+
const rows = rowsWith(held.send, row, { kind: 'remove' });
|
|
650
|
+
await store.updateSend({
|
|
651
|
+
key: sendKey,
|
|
652
|
+
version: held.version,
|
|
653
|
+
send: rows.length === 0
|
|
654
|
+
? undefined
|
|
655
|
+
: { ...held.send, rows, changedAt: formatNotificationInstant(at) },
|
|
656
|
+
});
|
|
657
|
+
return {};
|
|
658
|
+
}
|
|
659
|
+
const token = newToken();
|
|
660
|
+
// The replay window opens at the first claim that may reach the
|
|
661
|
+
// provider; paths that prove nothing was sent write the earlier value back.
|
|
662
|
+
// A replay of a row that may have been sent (an ambiguous answer, or a
|
|
663
|
+
// holder that died mid-call) counts as one more ambiguous attempt, so
|
|
664
|
+
// its lease grows with each replay either way.
|
|
665
|
+
const replayed = row.state.status === 'in-flight' && row.state.attempted;
|
|
666
|
+
const ambiguousAttempts = (row.ambiguousAttempts ?? 0) + (replayed ? 1 : 0);
|
|
667
|
+
const claimedRow = {
|
|
668
|
+
...row,
|
|
669
|
+
firstAttemptedAt: row.firstAttemptedAt ?? formatNotificationInstant(at),
|
|
670
|
+
...(ambiguousAttempts === 0 ? {} : { ambiguousAttempts }),
|
|
671
|
+
state: {
|
|
672
|
+
status: 'in-flight',
|
|
673
|
+
leaseUntil: formatNotificationInstant(at + ambiguousLeaseMs(ambiguousAttempts)),
|
|
674
|
+
claim: token,
|
|
675
|
+
attempted: true,
|
|
676
|
+
attemptedAt: formatNotificationInstant(at),
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
const next = {
|
|
680
|
+
...held.send,
|
|
681
|
+
// A due replay marks the send as the scheduler's.
|
|
682
|
+
scheduled: held.send.scheduled || mode === 'due',
|
|
683
|
+
rows: rowsWith(held.send, row, { kind: 'set', row: claimedRow }),
|
|
684
|
+
changedAt: formatNotificationInstant(at),
|
|
685
|
+
};
|
|
686
|
+
if (await store.updateSend({ key: sendKey, version: held.version, send: next })) {
|
|
687
|
+
claimed = { send: next, before: row, row: claimedRow, token, at };
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (claimed === undefined) {
|
|
691
|
+
throw busy(sendKey);
|
|
692
|
+
}
|
|
693
|
+
const { send, before, row, token } = claimed;
|
|
694
|
+
const registered = registry.provider(row.provider);
|
|
695
|
+
if (registered === undefined) {
|
|
696
|
+
// The deployment no longer serves this provider.
|
|
697
|
+
await complete(sendKey, row, token, { kind: 'remove' }, claimed.at);
|
|
698
|
+
return { refusal: noEndpoint() };
|
|
699
|
+
}
|
|
700
|
+
let evaluated;
|
|
701
|
+
let recipient;
|
|
702
|
+
let at;
|
|
703
|
+
try {
|
|
704
|
+
recipient = await recipientOfRow(registered, send.request, row, claimed.at);
|
|
705
|
+
const consent = await store.listConsent(send.request.player);
|
|
706
|
+
const early = earlyRefusal(registered, (standIn) => decide(send.request, send.origin, recipient, consent, [], standIn, nowMs()));
|
|
707
|
+
const serverPermission = early === undefined
|
|
708
|
+
? await readServerPermission(registered, send.request)
|
|
709
|
+
: undefined;
|
|
710
|
+
at = nowMs();
|
|
711
|
+
evaluated = early ?? decide(send.request, send.origin, recipient, consent, historyOn(await loadHistory(send.request.player, sendKey, at, { key: sendKey, claimedAt: claimed.at }), row.provider), serverPermission, at);
|
|
712
|
+
}
|
|
713
|
+
catch (error) {
|
|
714
|
+
if (error instanceof NotificationSendInputError) {
|
|
715
|
+
// A send that cannot be made as asked fails once, not on every replay.
|
|
716
|
+
await settleQuietly(sendKey, row, token, { kind: 'remove' }, claimed.at);
|
|
717
|
+
throw invalidInput(error.message, error);
|
|
718
|
+
}
|
|
719
|
+
// Nothing was sent (a host read or the store failed): the row goes back
|
|
720
|
+
// as it was, due again after a short backoff so a failing host does not
|
|
721
|
+
// fill every scheduler batch.
|
|
722
|
+
await settleQuietly(sendKey, row, token, { kind: 'set', row: backedOff(before, nowMs()) }, claimed.at);
|
|
723
|
+
throw error;
|
|
724
|
+
}
|
|
725
|
+
const { decision, topic } = evaluated;
|
|
726
|
+
const backoff = row.rateLimit === undefined ? {} : { rateLimit: row.rateLimit };
|
|
727
|
+
const base = {
|
|
728
|
+
key: row.key,
|
|
729
|
+
provider: row.provider,
|
|
730
|
+
...(row.installationId === undefined ? {} : { installationId: row.installationId }),
|
|
731
|
+
...(before.firstAttemptedAt === undefined ? {} : { firstAttemptedAt: before.firstAttemptedAt }),
|
|
732
|
+
...(before.ambiguousAttempts === undefined ? {} : { ambiguousAttempts: before.ambiguousAttempts }),
|
|
733
|
+
};
|
|
734
|
+
if (decision.status === 'deferred') {
|
|
735
|
+
await complete(sendKey, row, token, {
|
|
736
|
+
kind: 'set',
|
|
737
|
+
row: {
|
|
738
|
+
...base,
|
|
739
|
+
...backoff,
|
|
740
|
+
state: { status: 'deferred', reason: decision.reason, until: decision.until },
|
|
741
|
+
},
|
|
742
|
+
}, at);
|
|
743
|
+
return {};
|
|
744
|
+
}
|
|
745
|
+
if (decision.status !== 'allowed' || topic === undefined) {
|
|
746
|
+
await complete(sendKey, row, token, { kind: 'remove' }, at);
|
|
747
|
+
return { refusal: decision.status === 'allowed' ? noEndpoint() : decision };
|
|
748
|
+
}
|
|
749
|
+
const answer = await callProvider(recipient, send.request, topic, decision, at);
|
|
750
|
+
const answeredAt = nowMs();
|
|
751
|
+
switch (answer.status) {
|
|
752
|
+
case 'accepted':
|
|
753
|
+
// Counted before the row is settled: the provider took it whatever
|
|
754
|
+
// happens next. A history write that keeps failing is reported, and
|
|
755
|
+
// the row still settles as delivered: an undercounted limit is
|
|
756
|
+
// better than a replay that sends the push twice.
|
|
757
|
+
await withRetries(() => store.addHistory({
|
|
758
|
+
sendKey,
|
|
759
|
+
provider: row.provider,
|
|
760
|
+
player: send.request.player,
|
|
761
|
+
topic: send.request.topic,
|
|
762
|
+
at: formatNotificationInstant(answeredAt),
|
|
763
|
+
}), (error) => report(error, 'history.add', { sendKey, provider: row.provider, rowKey: row.key }));
|
|
764
|
+
await settleAfterCall(sendKey, row, token, { kind: 'set', row: { ...base, state: { status: 'delivered' } } }, answeredAt);
|
|
765
|
+
return {};
|
|
766
|
+
case 'ambiguous': {
|
|
767
|
+
// The provider may have sent it: the claim is kept for the lease.
|
|
768
|
+
const kept = row.state.status === 'in-flight' ? row.state : undefined;
|
|
769
|
+
// The first replay waits the claim lease, and each later ambiguous
|
|
770
|
+
// answer doubles the wait, up to an hour: a provider without an
|
|
771
|
+
// idempotency key may show every replay. The next claim counts the replay.
|
|
772
|
+
const ambiguousAttempts = row.ambiguousAttempts ?? 0;
|
|
773
|
+
const leaseUntil = answeredAt + ambiguousLeaseMs(ambiguousAttempts);
|
|
774
|
+
// The replay window started at the claim that reached the provider (or an earlier one).
|
|
775
|
+
const firstAttemptedAt = row.firstAttemptedAt ?? formatNotificationInstant(at);
|
|
776
|
+
if (isNotificationRowAbandoned({ ...row, firstAttemptedAt }, leaseUntil)) {
|
|
777
|
+
report(new Error('Ambiguous answers for a day: the delivery stays in flight and is not replayed again.'), 'dispatch.abandoned', { sendKey, provider: row.provider, rowKey: row.key });
|
|
778
|
+
}
|
|
779
|
+
await settleAfterCall(sendKey, row, token, {
|
|
780
|
+
kind: 'set',
|
|
781
|
+
row: {
|
|
782
|
+
...base,
|
|
783
|
+
...backoff,
|
|
784
|
+
firstAttemptedAt,
|
|
785
|
+
...(ambiguousAttempts === 0 ? {} : { ambiguousAttempts }),
|
|
786
|
+
state: {
|
|
787
|
+
status: 'in-flight',
|
|
788
|
+
leaseUntil: formatNotificationInstant(leaseUntil),
|
|
789
|
+
claim: token,
|
|
790
|
+
attempted: true,
|
|
791
|
+
...(kept?.attemptedAt === undefined ? {} : { attemptedAt: kept.attemptedAt }),
|
|
792
|
+
},
|
|
793
|
+
},
|
|
794
|
+
}, answeredAt);
|
|
795
|
+
return {};
|
|
796
|
+
}
|
|
797
|
+
case 'unregistered':
|
|
798
|
+
if (recipient.endpoint !== undefined) {
|
|
799
|
+
await store.invalidateEndpoint({
|
|
800
|
+
provider: row.provider,
|
|
801
|
+
installationId: recipient.endpoint.installationId,
|
|
802
|
+
value: recipient.endpoint.address.value,
|
|
803
|
+
at: formatNotificationInstant(answeredAt),
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
await complete(sendKey, row, token, { kind: 'remove' }, answeredAt);
|
|
807
|
+
return { refusal: { status: 'unavailable', reason: 'endpoint-invalid' } };
|
|
808
|
+
case 'rejected':
|
|
809
|
+
// A configuration rejection fails every send the same way: tell the
|
|
810
|
+
// operator. A recipient or agreement refusal is the player's, and a
|
|
811
|
+
// message that would arrive too late is routine; neither is an alert.
|
|
812
|
+
if (answer.reason === 'configuration' && answer.providerCode !== notificationExpiredProviderCode) {
|
|
813
|
+
report(new Error(`The provider rejected the delivery (configuration${answer.providerCode === undefined ? '' : `, ${answer.providerCode}`}).`), 'provider.rejected', { sendKey, provider: row.provider, rowKey: row.key });
|
|
814
|
+
}
|
|
815
|
+
if (answer.reason === 'agreement') {
|
|
816
|
+
await recordProviderDenial(send.request.player, send.request.topic, answeredAt);
|
|
817
|
+
}
|
|
818
|
+
await complete(sendKey, row, token, { kind: 'remove' }, answeredAt);
|
|
819
|
+
return { refusal: { status: 'unavailable', reason: 'provider-rejected' } };
|
|
820
|
+
case 'rate-limited': {
|
|
821
|
+
// Nothing was sent: the claim is released and the row waits.
|
|
822
|
+
const next = nextRateLimit(row.rateLimit, answer.retryAfterSeconds, answeredAt);
|
|
823
|
+
if (next === undefined) {
|
|
824
|
+
await complete(sendKey, row, token, { kind: 'remove' }, answeredAt);
|
|
825
|
+
return { refusal: { status: 'unavailable', reason: 'provider-rate-limited' } };
|
|
826
|
+
}
|
|
827
|
+
await complete(sendKey, row, token, {
|
|
828
|
+
kind: 'set',
|
|
829
|
+
row: {
|
|
830
|
+
...base,
|
|
831
|
+
rateLimit: next.state,
|
|
832
|
+
state: { status: 'deferred', reason: 'provider-rate-limited', until: next.until },
|
|
833
|
+
},
|
|
834
|
+
}, answeredAt);
|
|
835
|
+
return {};
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
/**
|
|
840
|
+
* `complete` after the provider accepted or answered ambiguously: a
|
|
841
|
+
* failure here leaves a delivery that may have happened, which is not the
|
|
842
|
+
* same as nothing sent, so it is marked for `runInline`.
|
|
843
|
+
*/
|
|
844
|
+
const settleAfterCall = async (sendKey, row, token, change, at) => {
|
|
845
|
+
try {
|
|
846
|
+
await complete(sendKey, row, token, change, at);
|
|
847
|
+
}
|
|
848
|
+
catch (error) {
|
|
849
|
+
throw new NotificationSettleError({ cause: error });
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
/** `complete` on a failure path: its own failure is reported, never thrown over the original. */
|
|
853
|
+
const settleQuietly = async (sendKey, row, token, change, at) => {
|
|
854
|
+
try {
|
|
855
|
+
await complete(sendKey, row, token, change, at);
|
|
856
|
+
}
|
|
857
|
+
catch (error) {
|
|
858
|
+
report(error, 'ledger.release', sendKey);
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
/**
|
|
862
|
+
* The host says the recipient has not agreed: a topic that needs an
|
|
863
|
+
* agreement stops until the player agrees again. A denial already latest
|
|
864
|
+
* for the topic is not written again, so repeated refusals of a topic that
|
|
865
|
+
* needs no agreement do not grow the history. Two refusals settled at the
|
|
866
|
+
* same moment may both write one; the latest record is a denial either way.
|
|
867
|
+
*/
|
|
868
|
+
const recordProviderDenial = async (player, topic, at) => {
|
|
869
|
+
const latest = latestNotificationConsent(await store.listConsent(player), 'host-agreement', topic);
|
|
870
|
+
if (latest?.state === 'denied') {
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
await store.appendConsent({
|
|
874
|
+
player,
|
|
875
|
+
record: { kind: 'host-agreement', topic, state: 'denied', source: 'provider' },
|
|
876
|
+
at: formatNotificationInstant(at),
|
|
877
|
+
});
|
|
878
|
+
};
|
|
879
|
+
/** The send's answer after its rows ran: the stored outcome, else the best refusal. */
|
|
880
|
+
const settle = async (sendKey, refusals) => {
|
|
881
|
+
const held = await store.getSend(sendKey);
|
|
882
|
+
const stored = held === undefined ? undefined : notificationSendOutcome(held.send);
|
|
883
|
+
return stored ?? bestNotificationOutcome(refusals) ?? noEndpoint();
|
|
884
|
+
};
|
|
885
|
+
/**
|
|
886
|
+
* Runs rows one after the other. One row's failure does not stop the
|
|
887
|
+
* others: its row stays due, and the failure is reported. A caller error
|
|
888
|
+
* is thrown once every row ran, and so is the first failure when no row
|
|
889
|
+
* got anywhere, so a caller never mistakes a send that did nothing for an
|
|
890
|
+
* ambiguous one.
|
|
891
|
+
*/
|
|
892
|
+
const runInline = async (sendKey, rows, mode) => {
|
|
893
|
+
const refusals = [];
|
|
894
|
+
let callerError;
|
|
895
|
+
const errors = [];
|
|
896
|
+
for (const row of rows) {
|
|
897
|
+
try {
|
|
898
|
+
const { refusal } = await runRow(sendKey, row.provider, row.key, mode);
|
|
899
|
+
if (refusal !== undefined) {
|
|
900
|
+
refusals.push(refusal);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
catch (error) {
|
|
904
|
+
if (error instanceof NotificationError && error.code === 'NOTIFICATIONS_INVALID_INPUT') {
|
|
905
|
+
callerError ??= error;
|
|
906
|
+
}
|
|
907
|
+
else {
|
|
908
|
+
errors.push({ error, row });
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
const failed = errors.length + (callerError === undefined ? 0 : 1);
|
|
913
|
+
// When every row failed before its provider call, the first failure is
|
|
914
|
+
// the call's and the rest are reported. A row whose answer could not be
|
|
915
|
+
// stored may have been delivered, so the call answers from the ledger.
|
|
916
|
+
const nothingSent = errors.every(({ error }) => !(error instanceof NotificationSettleError));
|
|
917
|
+
const thrown = callerError
|
|
918
|
+
?? (failed === rows.length && nothingSent ? errors[0]?.error : undefined);
|
|
919
|
+
for (const { error, row } of errors) {
|
|
920
|
+
if (error !== thrown) {
|
|
921
|
+
report(error, 'dispatch', { sendKey, provider: row.provider, rowKey: row.key });
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
if (thrown !== undefined) {
|
|
925
|
+
throw thrown;
|
|
926
|
+
}
|
|
927
|
+
return { refusals, failed };
|
|
928
|
+
};
|
|
929
|
+
/** Evaluates a send this call created, holding its first-evaluation claim. */
|
|
930
|
+
const sendNew = async (send, version, at) => {
|
|
931
|
+
let planned;
|
|
932
|
+
try {
|
|
933
|
+
planned = await plan(send.request, send.origin, at);
|
|
934
|
+
}
|
|
935
|
+
catch (error) {
|
|
936
|
+
// Nothing was sent: the key is released so a retry evaluates again.
|
|
937
|
+
try {
|
|
938
|
+
await store.updateSend({ key: send.key, version, send: undefined });
|
|
939
|
+
}
|
|
940
|
+
catch (releaseError) {
|
|
941
|
+
report(releaseError, 'ledger.release', send.key);
|
|
942
|
+
}
|
|
943
|
+
if (error instanceof NotificationSendInputError) {
|
|
944
|
+
throw invalidInput(error.message, error);
|
|
945
|
+
}
|
|
946
|
+
throw error;
|
|
947
|
+
}
|
|
948
|
+
const { rows, refusals } = planned;
|
|
949
|
+
if (rows.length === 0) {
|
|
950
|
+
await store.updateSend({ key: send.key, version, send: undefined });
|
|
951
|
+
return bestNotificationOutcome(refusals) ?? noEndpoint();
|
|
952
|
+
}
|
|
953
|
+
const { claim: _claim, ...unclaimed } = send;
|
|
954
|
+
const stored = {
|
|
955
|
+
...unclaimed,
|
|
956
|
+
rows,
|
|
957
|
+
changedAt: formatNotificationInstant(at),
|
|
958
|
+
};
|
|
959
|
+
if (!await store.updateSend({ key: send.key, version, send: stored })) {
|
|
960
|
+
// The first-evaluation lease ran out and another caller took the key.
|
|
961
|
+
return { status: 'in-flight' };
|
|
962
|
+
}
|
|
963
|
+
const queued = rows.filter((row) => row.state.status === 'in-flight');
|
|
964
|
+
if (queued.length > 0 && input.queue !== undefined) {
|
|
965
|
+
try {
|
|
966
|
+
await input.queue.enqueue(queued.map((row) => ({ sendKey: send.key, provider: row.provider, rowKey: row.key })));
|
|
967
|
+
}
|
|
968
|
+
catch (error) {
|
|
969
|
+
// The rows are stored with a lease: `dispatchDue` runs them once it ends.
|
|
970
|
+
report(error, 'queue.enqueue', send.key);
|
|
971
|
+
}
|
|
972
|
+
return notificationSendOutcome(stored) ?? { status: 'in-flight' };
|
|
973
|
+
}
|
|
974
|
+
const ran = await runInline(send.key, queued, 'job');
|
|
975
|
+
return settle(send.key, [...refusals, ...ran.refusals]);
|
|
976
|
+
};
|
|
977
|
+
// Due rows (the scheduler's dispatch).
|
|
978
|
+
/**
|
|
979
|
+
* Takes due rows for the queue: each becomes a queued row (in flight, no
|
|
980
|
+
* holder) for one claim lease, so the next run does not take it again
|
|
981
|
+
* while its job waits, and a lost job is due again when the lease ends.
|
|
982
|
+
* An attempted row stays attempted (its provider call may have happened),
|
|
983
|
+
* and the take never closes its replay window early. Answers the rows it
|
|
984
|
+
* took; a row that is no longer due is left.
|
|
985
|
+
*/
|
|
986
|
+
const takeRows = async (sendKey, wanted) => {
|
|
987
|
+
const names = new Set(wanted.map((row) => JSON.stringify([row.key, row.provider])));
|
|
988
|
+
for (let attempt = 0; attempt < maxLedgerAttempts; attempt += 1) {
|
|
989
|
+
const held = await store.getSend(sendKey);
|
|
990
|
+
if (held === undefined) {
|
|
991
|
+
return [];
|
|
992
|
+
}
|
|
993
|
+
const at = nowMs();
|
|
994
|
+
const taken = [];
|
|
995
|
+
const rows = held.send.rows.map((row) => {
|
|
996
|
+
if (!names.has(JSON.stringify([row.key, row.provider])) || !isNotificationRowDue(row, at)) {
|
|
997
|
+
return row;
|
|
998
|
+
}
|
|
999
|
+
const next = takenRow(row, at, leaseMs);
|
|
1000
|
+
taken.push(next);
|
|
1001
|
+
return next;
|
|
1002
|
+
});
|
|
1003
|
+
if (taken.length === 0) {
|
|
1004
|
+
return [];
|
|
1005
|
+
}
|
|
1006
|
+
if (await store.updateSend({
|
|
1007
|
+
key: sendKey,
|
|
1008
|
+
version: held.version,
|
|
1009
|
+
send: { ...held.send, scheduled: true, rows, changedAt: formatNotificationInstant(at) },
|
|
1010
|
+
})) {
|
|
1011
|
+
return taken;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
throw busy(sendKey);
|
|
1015
|
+
};
|
|
1016
|
+
/**
|
|
1017
|
+
* The remaining dispatches each provider allows this run: its registry
|
|
1018
|
+
* `rate` less the deliveries in the rate window (`t − window < s ≤ t`),
|
|
1019
|
+
* read once per run, less every row this run took since. Sends still in
|
|
1020
|
+
* flight from earlier runs are not counted, so they may pass it by a few.
|
|
1021
|
+
*/
|
|
1022
|
+
const paceBudgets = () => {
|
|
1023
|
+
const budgets = new Map();
|
|
1024
|
+
return {
|
|
1025
|
+
async take(provider) {
|
|
1026
|
+
let left = budgets.get(provider);
|
|
1027
|
+
if (left === undefined) {
|
|
1028
|
+
const rate = registry.provider(provider)?.entry.rate;
|
|
1029
|
+
const at = nowMs();
|
|
1030
|
+
left = rate === undefined
|
|
1031
|
+
? Number.POSITIVE_INFINITY
|
|
1032
|
+
: rate.count - await store.countHistory({
|
|
1033
|
+
provider,
|
|
1034
|
+
since: formatNotificationInstant(at - rate.windowSeconds * secondMs + 1),
|
|
1035
|
+
// Deliveries dated after now (the clock went back) are outside the window.
|
|
1036
|
+
until: formatNotificationInstant(at),
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
budgets.set(provider, left - 1);
|
|
1040
|
+
return left > 0;
|
|
1041
|
+
},
|
|
1042
|
+
};
|
|
1043
|
+
};
|
|
1044
|
+
/**
|
|
1045
|
+
* One batch of due rows. `pace` carries each provider's remaining budget
|
|
1046
|
+
* across the batches of one scheduler run, so rows taken in an earlier
|
|
1047
|
+
* batch (still in flight, not yet counted as delivered) count too.
|
|
1048
|
+
* Answers how many sends were listed and how many of them the pace held
|
|
1049
|
+
* back entirely.
|
|
1050
|
+
*/
|
|
1051
|
+
const dispatchDueRows = async (options = {}, pace = paceBudgets()) => {
|
|
1052
|
+
const limit = batchLimit(options.limit);
|
|
1053
|
+
const keys = await store.listDueSends({ at: formatNotificationInstant(nowMs()), limit });
|
|
1054
|
+
const listed = keys.length;
|
|
1055
|
+
const results = [];
|
|
1056
|
+
let failed = 0;
|
|
1057
|
+
let paced = 0;
|
|
1058
|
+
for (const sendKey of keys) {
|
|
1059
|
+
try {
|
|
1060
|
+
const held = await store.getSend(sendKey);
|
|
1061
|
+
if (held === undefined) {
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
const at = nowMs();
|
|
1065
|
+
// Oldest first within each provider's pace: a row over it stays due for the next run.
|
|
1066
|
+
const due = [];
|
|
1067
|
+
let heldBack = false;
|
|
1068
|
+
for (const row of held.send.rows) {
|
|
1069
|
+
if (isNotificationRowDue(row, at)) {
|
|
1070
|
+
if (await pace.take(row.provider)) {
|
|
1071
|
+
due.push(row);
|
|
1072
|
+
}
|
|
1073
|
+
else {
|
|
1074
|
+
heldBack = true;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
if (due.length === 0) {
|
|
1079
|
+
paced += heldBack ? 1 : 0;
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
if (input.queue !== undefined) {
|
|
1083
|
+
const taken = await takeRows(sendKey, due);
|
|
1084
|
+
if (taken.length === 0) {
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
try {
|
|
1088
|
+
await input.queue.enqueue(taken.map((row) => ({ sendKey, provider: row.provider, rowKey: row.key })));
|
|
1089
|
+
}
|
|
1090
|
+
catch (error) {
|
|
1091
|
+
// The rows are taken for one lease: the next run takes them again after it.
|
|
1092
|
+
failed += 1;
|
|
1093
|
+
report(error, 'queue.enqueue', sendKey);
|
|
1094
|
+
}
|
|
1095
|
+
results.push({ sendKey, outcome: await settle(sendKey, []) });
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
const ran = await runInline(sendKey, due, 'due');
|
|
1099
|
+
if (ran.failed > 0) {
|
|
1100
|
+
failed += 1;
|
|
1101
|
+
}
|
|
1102
|
+
results.push({ sendKey, outcome: await settle(sendKey, ran.refusals) });
|
|
1103
|
+
}
|
|
1104
|
+
catch (error) {
|
|
1105
|
+
failed += 1;
|
|
1106
|
+
report(error, 'dispatchDue', sendKey);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
return { results, failed, listed, paced };
|
|
1110
|
+
};
|
|
1111
|
+
// Scheduler items.
|
|
1112
|
+
/**
|
|
1113
|
+
* The send a scheduler item makes, keyed by the item: a new key held by a
|
|
1114
|
+
* first-evaluation claim before any row (`created`), the stored send when
|
|
1115
|
+
* one with rows exists (`existing`), or `busy` while another run holds the
|
|
1116
|
+
* key without rows yet. A key whose holder stopped before storing a row
|
|
1117
|
+
* starts over once its lease ends.
|
|
1118
|
+
*/
|
|
1119
|
+
const createItemSend = async (request, origin, at) => {
|
|
1120
|
+
for (let attempt = 0; attempt < maxLedgerAttempts; attempt += 1) {
|
|
1121
|
+
const held = await store.getSend(request.idempotencyKey);
|
|
1122
|
+
if (held !== undefined) {
|
|
1123
|
+
const { send } = held;
|
|
1124
|
+
if (send.rows.length > 0) {
|
|
1125
|
+
return { existing: send };
|
|
1126
|
+
}
|
|
1127
|
+
if (send.claim !== undefined && Date.parse(send.claim.leaseUntil) > at) {
|
|
1128
|
+
return 'busy';
|
|
1129
|
+
}
|
|
1130
|
+
await store.updateSend({ key: send.key, version: held.version, send: undefined });
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
const send = {
|
|
1134
|
+
key: request.idempotencyKey,
|
|
1135
|
+
fingerprint: notificationSendFingerprint(request),
|
|
1136
|
+
request,
|
|
1137
|
+
origin,
|
|
1138
|
+
scheduled: true,
|
|
1139
|
+
rows: [],
|
|
1140
|
+
claim: { token: newToken(), leaseUntil: formatNotificationInstant(at + leaseMs) },
|
|
1141
|
+
changedAt: formatNotificationInstant(at),
|
|
1142
|
+
};
|
|
1143
|
+
const version = await store.createSend(send);
|
|
1144
|
+
if (version !== undefined) {
|
|
1145
|
+
return { created: { send, version } };
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
throw busy(request.idempotencyKey);
|
|
1149
|
+
};
|
|
1150
|
+
const releaseQuietly = async (key, version) => {
|
|
1151
|
+
try {
|
|
1152
|
+
await store.updateSend({ key, version, send: undefined });
|
|
1153
|
+
}
|
|
1154
|
+
catch (error) {
|
|
1155
|
+
report(error, 'ledger.release', key);
|
|
1156
|
+
}
|
|
1157
|
+
};
|
|
1158
|
+
/**
|
|
1159
|
+
* Plans the rows of a send `createItemSend` made and stores them, due at
|
|
1160
|
+
* `dueAt`. Answers the send's result and whether rows were stored (a gate
|
|
1161
|
+
* refusal stores none and releases the key); `undefined` when a cancel
|
|
1162
|
+
* removed the key meanwhile, so nothing was stored. A caller error
|
|
1163
|
+
* releases the key and throws `NOTIFICATIONS_INVALID_INPUT`; any other
|
|
1164
|
+
* failure releases it and rethrows.
|
|
1165
|
+
*/
|
|
1166
|
+
const storeItemRows = async (created, at, dueAt) => {
|
|
1167
|
+
const { send, version } = created;
|
|
1168
|
+
let planned;
|
|
1169
|
+
try {
|
|
1170
|
+
planned = await plan(send.request, send.origin, at, Math.min(dueAt, at));
|
|
1171
|
+
}
|
|
1172
|
+
catch (error) {
|
|
1173
|
+
await releaseQuietly(send.key, version);
|
|
1174
|
+
if (error instanceof NotificationSendInputError) {
|
|
1175
|
+
throw invalidInput(error.message, error);
|
|
1176
|
+
}
|
|
1177
|
+
throw error;
|
|
1178
|
+
}
|
|
1179
|
+
if (planned.rows.length === 0) {
|
|
1180
|
+
await releaseQuietly(send.key, version);
|
|
1181
|
+
return { outcome: bestNotificationOutcome(planned.refusals) ?? noEndpoint(), stored: false };
|
|
1182
|
+
}
|
|
1183
|
+
const { claim: _claim, ...unclaimed } = send;
|
|
1184
|
+
const stored = {
|
|
1185
|
+
...unclaimed,
|
|
1186
|
+
rows: planned.rows,
|
|
1187
|
+
changedAt: formatNotificationInstant(at),
|
|
1188
|
+
};
|
|
1189
|
+
// A cancel since the key was made removed it: nothing is stored or sent.
|
|
1190
|
+
if (!await store.updateSend({ key: send.key, version, send: stored })) {
|
|
1191
|
+
return undefined;
|
|
1192
|
+
}
|
|
1193
|
+
return { outcome: notificationSendOutcome(stored) ?? { status: 'in-flight' }, stored: true };
|
|
1194
|
+
};
|
|
1195
|
+
/** The content of a reminder's send: kept on a fired reminder, which drops the rest. */
|
|
1196
|
+
const reminderFingerprint = (player, reminder) => reminder.fingerprint ?? notificationSendFingerprint(reminderRequest(player, reminder));
|
|
1197
|
+
/** The send a reminder makes: its request, whose fingerprint names its content. */
|
|
1198
|
+
const reminderRequest = (player, reminder) => ({
|
|
1199
|
+
idempotencyKey: createNotificationReminderIdempotencyKey({
|
|
1200
|
+
player,
|
|
1201
|
+
key: reminder.key,
|
|
1202
|
+
fireAt: reminder.fireAt,
|
|
1203
|
+
}),
|
|
1204
|
+
player,
|
|
1205
|
+
topic: reminder.topic,
|
|
1206
|
+
...(reminder.templateData === undefined ? {} : { templateData: reminder.templateData }),
|
|
1207
|
+
...(reminder.link === undefined ? {} : { link: reminder.link }),
|
|
1208
|
+
});
|
|
1209
|
+
/**
|
|
1210
|
+
* Stops a reminder's send that still waits: the rows not delivered yet
|
|
1211
|
+
* (deferred, queued, or in flight) are dropped, and a send the scheduler
|
|
1212
|
+
* is still making loses its key, so no row of it is ever stored. Only a
|
|
1213
|
+
* send of `fingerprint` (the reminder's own content) is stopped, since the
|
|
1214
|
+
* send key names only the key and time: a send of other content at the key
|
|
1215
|
+
* belongs to the reminder that replaced this one. A send the provider may
|
|
1216
|
+
* already have accepted cannot be recalled. Answers whether anything waited.
|
|
1217
|
+
*/
|
|
1218
|
+
const stopReminderSend = async (player, reminder, fingerprint) => {
|
|
1219
|
+
let key;
|
|
1220
|
+
try {
|
|
1221
|
+
key = createNotificationReminderIdempotencyKey({ player, key: reminder.key, fireAt: reminder.fireAt });
|
|
1222
|
+
}
|
|
1223
|
+
catch {
|
|
1224
|
+
return false;
|
|
1225
|
+
}
|
|
1226
|
+
for (let attempt = 0; attempt < maxLedgerAttempts; attempt += 1) {
|
|
1227
|
+
const held = await store.getSend(key);
|
|
1228
|
+
if (held === undefined || held.send.origin.kind !== 'reminder' || held.send.fingerprint !== fingerprint) {
|
|
1229
|
+
return false;
|
|
1230
|
+
}
|
|
1231
|
+
const { send } = held;
|
|
1232
|
+
const kept = send.rows.filter((row) => row.state.status === 'delivered');
|
|
1233
|
+
if (send.rows.length > 0 && kept.length === send.rows.length) {
|
|
1234
|
+
return false;
|
|
1235
|
+
}
|
|
1236
|
+
const next = kept.length === 0
|
|
1237
|
+
? undefined
|
|
1238
|
+
: { ...send, rows: kept, changedAt: formatNotificationInstant(nowMs()) };
|
|
1239
|
+
if (await store.updateSend({ key, version: held.version, send: next })) {
|
|
1240
|
+
return true;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
throw busy(key);
|
|
1244
|
+
};
|
|
1245
|
+
/** Moves an entry's due time later after a failure, so a broken item does not fill every run. */
|
|
1246
|
+
const deferEntry = async (held, at) => {
|
|
1247
|
+
try {
|
|
1248
|
+
await store.updateScheduleEntry({
|
|
1249
|
+
player: held.entry.player,
|
|
1250
|
+
id: held.entry.id,
|
|
1251
|
+
version: held.version,
|
|
1252
|
+
entry: { ...held.entry, dueAt: formatNotificationInstant(at + schedulerRetryMs) },
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
catch (error) {
|
|
1256
|
+
report(error, 'scheduler.defer');
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
const removeEntry = (held) => store.updateScheduleEntry({
|
|
1260
|
+
player: held.entry.player,
|
|
1261
|
+
id: held.entry.id,
|
|
1262
|
+
version: held.version,
|
|
1263
|
+
entry: undefined,
|
|
1264
|
+
});
|
|
1265
|
+
/**
|
|
1266
|
+
* A due reminder becomes its `reminder:<player>:<key>:<fireAt>` send to
|
|
1267
|
+
* every provider the player has a recipient on, through the gate. The
|
|
1268
|
+
* rows are stored first and the reminder is marked fired after (kept until
|
|
1269
|
+
* its key's retention ends, so a later set or cancel can still stop the
|
|
1270
|
+
* send), so a run that stops between them leaves the reminder pending and
|
|
1271
|
+
* the next run finds its rows. A refused send spends the reminder. A set or
|
|
1272
|
+
* cancel since the read wins: it stops the send, and so does this run.
|
|
1273
|
+
* Answers `busy` while another run is making the send, and `stale` when
|
|
1274
|
+
* this run read a reminder a set has replaced since: neither worked it.
|
|
1275
|
+
*/
|
|
1276
|
+
const fireReminder = async (held, reminder) => {
|
|
1277
|
+
const { player } = held.entry;
|
|
1278
|
+
const at = nowMs();
|
|
1279
|
+
let key;
|
|
1280
|
+
try {
|
|
1281
|
+
key = createNotificationReminderIdempotencyKey({ player, key: reminder.key, fireAt: reminder.fireAt });
|
|
1282
|
+
}
|
|
1283
|
+
catch (error) {
|
|
1284
|
+
// A reminder whose send cannot be keyed fails once.
|
|
1285
|
+
await removeEntry(held);
|
|
1286
|
+
throw invalidInput(describe(error), error);
|
|
1287
|
+
}
|
|
1288
|
+
const request = reminderRequest(player, reminder);
|
|
1289
|
+
const fingerprint = notificationSendFingerprint(request);
|
|
1290
|
+
// A fired reminder keeps only what names its send, and the fingerprint
|
|
1291
|
+
// of its own content (the canonical request, which the send also keeps).
|
|
1292
|
+
const fired = {
|
|
1293
|
+
player,
|
|
1294
|
+
id: held.entry.id,
|
|
1295
|
+
dueAt: formatNotificationInstant(at + keyRetentionMs),
|
|
1296
|
+
item: {
|
|
1297
|
+
kind: 'reminder',
|
|
1298
|
+
key: reminder.key,
|
|
1299
|
+
topic: reminder.topic,
|
|
1300
|
+
fireAt: reminder.fireAt,
|
|
1301
|
+
firedAt: formatNotificationInstant(at),
|
|
1302
|
+
fingerprint,
|
|
1303
|
+
},
|
|
1304
|
+
};
|
|
1305
|
+
const markFired = () => store.updateScheduleEntry({ player, id: held.entry.id, version: held.version, entry: fired });
|
|
1306
|
+
let made = await createItemSend(request, { kind: 'reminder', key: reminder.key }, at);
|
|
1307
|
+
if (made !== 'busy' && 'existing' in made && made.existing.fingerprint !== fingerprint) {
|
|
1308
|
+
// The key holds a send of other content. If this reminder is still
|
|
1309
|
+
// the current one (as of the re-read), that send is of a reminder it
|
|
1310
|
+
// replaced at the same time: what still waits of it goes, and this
|
|
1311
|
+
// reminder makes its own. If not, this run read a reminder a set has
|
|
1312
|
+
// replaced since, and the send is the newer reminder's: it is left
|
|
1313
|
+
// alone. A set that lands between the re-read and the stop stops this
|
|
1314
|
+
// reminder's own send and the next run repairs the rest.
|
|
1315
|
+
const current = await store.getScheduleEntry({ player, id: held.entry.id });
|
|
1316
|
+
if (current?.version !== held.version) {
|
|
1317
|
+
return 'stale';
|
|
1318
|
+
}
|
|
1319
|
+
await stopReminderSend(player, reminder, made.existing.fingerprint);
|
|
1320
|
+
made = await createItemSend(request, { kind: 'reminder', key: reminder.key }, at);
|
|
1321
|
+
if (made !== 'busy' && 'existing' in made && made.existing.fingerprint !== fingerprint) {
|
|
1322
|
+
// A row of it was delivered, so the key keeps that send: this
|
|
1323
|
+
// reminder cannot have its own under the same key and time.
|
|
1324
|
+
report(new Error("A delivered send of another reminder holds this reminder's key and time; it is not sent."), 'scheduler.reminder', key);
|
|
1325
|
+
await markFired();
|
|
1326
|
+
return undefined;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
if (made === 'busy') {
|
|
1330
|
+
// Another run is making the send; its lease frees the key if it stops.
|
|
1331
|
+
return 'busy';
|
|
1332
|
+
}
|
|
1333
|
+
if ('existing' in made) {
|
|
1334
|
+
// Its rows were stored by a run that stopped before marking it: they carry it.
|
|
1335
|
+
await markFired();
|
|
1336
|
+
return undefined;
|
|
1337
|
+
}
|
|
1338
|
+
let result;
|
|
1339
|
+
try {
|
|
1340
|
+
// The rows are due when the entry was: a past fireAt counts from its
|
|
1341
|
+
// set time. A pending reminder always has a due time (runScheduler
|
|
1342
|
+
// takes only entries with one); fireAt only satisfies the type.
|
|
1343
|
+
result = await storeItemRows(made.created, at, Date.parse(held.entry.dueAt ?? reminder.fireAt));
|
|
1344
|
+
}
|
|
1345
|
+
catch (error) {
|
|
1346
|
+
if (error instanceof NotificationError && error.code === 'NOTIFICATIONS_INVALID_INPUT') {
|
|
1347
|
+
// A send that cannot be made as asked fails once.
|
|
1348
|
+
await removeEntry(held);
|
|
1349
|
+
}
|
|
1350
|
+
throw error;
|
|
1351
|
+
}
|
|
1352
|
+
if (result === undefined) {
|
|
1353
|
+
return undefined;
|
|
1354
|
+
}
|
|
1355
|
+
if (!result.stored) {
|
|
1356
|
+
// Refused by the gate: the reminder is spent, as a send would be.
|
|
1357
|
+
await removeEntry(held);
|
|
1358
|
+
return { sendKey: key, outcome: result.outcome };
|
|
1359
|
+
}
|
|
1360
|
+
if (!await markFired()) {
|
|
1361
|
+
// The entry changed since the read. The rows serve it when it is the
|
|
1362
|
+
// same reminder (time and content), pending or marked fired by another
|
|
1363
|
+
// run that found them. A cancel, or a set to another time or content,
|
|
1364
|
+
// stops them: theirs runs before their write, and this one covers a
|
|
1365
|
+
// send stored after it. Only this content's send is stopped, so a send
|
|
1366
|
+
// the new reminder made at the same key stays. A read that fails stops
|
|
1367
|
+
// them too, so a cancel is never outlived by its send.
|
|
1368
|
+
let same = false;
|
|
1369
|
+
try {
|
|
1370
|
+
const now = (await store.getScheduleEntry({ player, id: held.entry.id }))?.entry.item;
|
|
1371
|
+
same = now?.kind === 'reminder'
|
|
1372
|
+
&& now.fireAt === reminder.fireAt
|
|
1373
|
+
&& reminderFingerprint(player, now) === fingerprint;
|
|
1374
|
+
}
|
|
1375
|
+
catch (error) {
|
|
1376
|
+
report(error, 'scheduler.reread');
|
|
1377
|
+
}
|
|
1378
|
+
if (!same) {
|
|
1379
|
+
await stopReminderSend(player, reminder, fingerprint);
|
|
1380
|
+
}
|
|
1381
|
+
return undefined;
|
|
1382
|
+
}
|
|
1383
|
+
return { sendKey: key, outcome: result.outcome };
|
|
1384
|
+
};
|
|
1385
|
+
/**
|
|
1386
|
+
* A fired reminder whose send key's retention ended is forgotten, unless
|
|
1387
|
+
* its send still waits (a long rate-limit or ambiguous replay keeps the
|
|
1388
|
+
* key alive): then it looks again a day later.
|
|
1389
|
+
*/
|
|
1390
|
+
const forgetFiredReminder = async (held, reminder) => {
|
|
1391
|
+
let key;
|
|
1392
|
+
try {
|
|
1393
|
+
key = createNotificationReminderIdempotencyKey({
|
|
1394
|
+
player: held.entry.player,
|
|
1395
|
+
key: reminder.key,
|
|
1396
|
+
fireAt: reminder.fireAt,
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
catch {
|
|
1400
|
+
key = undefined;
|
|
1401
|
+
}
|
|
1402
|
+
// A read that fails throws, so the entry waits a minute rather than going while its send may wait.
|
|
1403
|
+
const waiting = key !== undefined
|
|
1404
|
+
&& ((await store.getSend(key))?.send.rows.some((row) => row.state.status !== 'delivered') ?? false);
|
|
1405
|
+
if (!waiting) {
|
|
1406
|
+
await removeEntry(held);
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
await store.updateScheduleEntry({
|
|
1410
|
+
player: held.entry.player,
|
|
1411
|
+
id: held.entry.id,
|
|
1412
|
+
version: held.version,
|
|
1413
|
+
entry: { ...held.entry, dueAt: formatNotificationInstant(nowMs() + dayMs) },
|
|
1414
|
+
});
|
|
1415
|
+
};
|
|
1416
|
+
/** The instant an Asia/Seoul calendar date starts. */
|
|
1417
|
+
const seoulMidnight = (date) => Date.parse(`${date}T00:00:00+09:00`);
|
|
1418
|
+
const noticeIdentity = (notice) => ({
|
|
1419
|
+
kind: notice.kind,
|
|
1420
|
+
record: { kind: notice.record.kind, at: normalizeNotificationTimestamp(notice.record.at) },
|
|
1421
|
+
dueBy: notice.dueBy,
|
|
1422
|
+
});
|
|
1423
|
+
const carriesNotices = () => rules === 'kr'
|
|
1424
|
+
&& noticeTopic !== undefined
|
|
1425
|
+
&& registry.providers.some((provider) => notificationProviderCarriesNotices(provider.entry));
|
|
1426
|
+
/**
|
|
1427
|
+
* The player's notice check: each listed notice nobody recorded by the
|
|
1428
|
+
* start of the Asia/Seoul day seven days before its `dueBy` becomes its
|
|
1429
|
+
* `notice:` send through the notice topic, in the variant it is listed
|
|
1430
|
+
* with, filled with `consentDate`. Then the check is due again at the
|
|
1431
|
+
* earliest of: the next notice's first send day (a later result notice or
|
|
1432
|
+
* re-confirmation), an hour while a push waits in the ledger, and the
|
|
1433
|
+
* backoff after a refused push (an hour, doubling to a day). With nothing
|
|
1434
|
+
* owed it is removed.
|
|
1435
|
+
*/
|
|
1436
|
+
const checkNotices = async (held, check) => {
|
|
1437
|
+
const { player } = held.entry;
|
|
1438
|
+
if (!carriesNotices() || noticeTopic === undefined) {
|
|
1439
|
+
await removeEntry(held);
|
|
1440
|
+
return [];
|
|
1441
|
+
}
|
|
1442
|
+
const at = nowMs();
|
|
1443
|
+
const now = formatNotificationInstant(at);
|
|
1444
|
+
const today = toSeoulCalendarDate(now);
|
|
1445
|
+
try {
|
|
1446
|
+
// Only the player can make a notice key fail (an id with a lone surrogate): its check fails once.
|
|
1447
|
+
createNotificationNoticeIdempotencyKey({
|
|
1448
|
+
kind: 'consent-result',
|
|
1449
|
+
record: { kind: 'advertising', at: now },
|
|
1450
|
+
dueBy: today,
|
|
1451
|
+
player,
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
catch (error) {
|
|
1455
|
+
await removeEntry(held);
|
|
1456
|
+
report(error, 'scheduler.notice');
|
|
1457
|
+
return [];
|
|
1458
|
+
}
|
|
1459
|
+
const records = await store.listConsent(player);
|
|
1460
|
+
const given = await store.listNoticesGiven(player);
|
|
1461
|
+
const owed = listNotificationNotices(records, given, now);
|
|
1462
|
+
const failed = new Set(check.failed ?? []);
|
|
1463
|
+
const previouslyPushed = new Set(check.pushed ?? []);
|
|
1464
|
+
const pushed = [];
|
|
1465
|
+
const results = [];
|
|
1466
|
+
let waiting = false;
|
|
1467
|
+
let refused = false;
|
|
1468
|
+
for (const notice of owed) {
|
|
1469
|
+
const sendFrom = notificationNoticeSendFrom(notice.dueBy);
|
|
1470
|
+
const identity = noticeIdentity(notice);
|
|
1471
|
+
const key = createNotificationNoticeIdempotencyKey({ ...identity, player });
|
|
1472
|
+
if (sendFrom > today || failed.has(key)) {
|
|
1473
|
+
continue;
|
|
1474
|
+
}
|
|
1475
|
+
const existing = await store.getSend(key);
|
|
1476
|
+
if (existing === undefined && previouslyPushed.has(key)) {
|
|
1477
|
+
// The last check pushed it, and the push is gone while the notice is
|
|
1478
|
+
// still owed: the provider refused it when it was sent. It waits out
|
|
1479
|
+
// the backoff like a push the gate refused.
|
|
1480
|
+
refused = true;
|
|
1481
|
+
continue;
|
|
1482
|
+
}
|
|
1483
|
+
const request = {
|
|
1484
|
+
idempotencyKey: key,
|
|
1485
|
+
player,
|
|
1486
|
+
topic: noticeTopic,
|
|
1487
|
+
templateData: { consentDate: toSeoulCalendarDate(identity.record.at) },
|
|
1488
|
+
};
|
|
1489
|
+
const made = await createItemSend(request, { kind: 'notice', notice: identity, variant: notice.variant }, at);
|
|
1490
|
+
if (made === 'busy') {
|
|
1491
|
+
// Any waiting push stays this check's push, so a refusal after it backs off.
|
|
1492
|
+
waiting = true;
|
|
1493
|
+
pushed.push(key);
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1496
|
+
if ('existing' in made) {
|
|
1497
|
+
if (notificationSendOutcome(made.existing)?.status === 'delivered') {
|
|
1498
|
+
// Delivered, but its record was not written: it is recorded now (while it is listed), not pushed again.
|
|
1499
|
+
if (await noticeListed(player, identity, at)) {
|
|
1500
|
+
await store.addNoticeGiven({ ...identity, player, channel: 'push', at: now });
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
else {
|
|
1504
|
+
// Still waiting: it stays this check's push, so a later refusal still backs off.
|
|
1505
|
+
waiting = true;
|
|
1506
|
+
pushed.push(key);
|
|
1507
|
+
}
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1510
|
+
try {
|
|
1511
|
+
const result = await storeItemRows(made.created, at, seoulMidnight(sendFrom));
|
|
1512
|
+
if (result === undefined) {
|
|
1513
|
+
// Another run took the key over: its push is looked at again.
|
|
1514
|
+
waiting = true;
|
|
1515
|
+
}
|
|
1516
|
+
else {
|
|
1517
|
+
results.push({ sendKey: key, outcome: result.outcome });
|
|
1518
|
+
if (result.stored) {
|
|
1519
|
+
waiting = true;
|
|
1520
|
+
pushed.push(key);
|
|
1521
|
+
}
|
|
1522
|
+
else {
|
|
1523
|
+
refused = true;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
catch (error) {
|
|
1528
|
+
if (!(error instanceof NotificationError && error.code === 'NOTIFICATIONS_INVALID_INPUT')) {
|
|
1529
|
+
throw error;
|
|
1530
|
+
}
|
|
1531
|
+
// A push that cannot render fails once; the game still owes the notice.
|
|
1532
|
+
failed.add(key);
|
|
1533
|
+
report(error, 'scheduler.notice', key);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
// Every notice still owed, now or later (the next re-confirmation of each consent).
|
|
1537
|
+
const ahead = listNotificationNotices(records, given, noticeHorizon);
|
|
1538
|
+
const owedKeys = new Set(ahead.map((notice) => createNotificationNoticeIdempotencyKey({ ...noticeIdentity(notice), player })));
|
|
1539
|
+
const stillFailed = [...failed].filter((key) => owedKeys.has(key));
|
|
1540
|
+
let next;
|
|
1541
|
+
const consider = (time) => {
|
|
1542
|
+
next = next === undefined ? time : Math.min(next, time);
|
|
1543
|
+
};
|
|
1544
|
+
for (const notice of ahead) {
|
|
1545
|
+
const sendFrom = notificationNoticeSendFrom(notice.dueBy);
|
|
1546
|
+
if (sendFrom > today) {
|
|
1547
|
+
consider(seoulMidnight(sendFrom));
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
if (waiting) {
|
|
1551
|
+
consider(at + noticeRecheckMs);
|
|
1552
|
+
}
|
|
1553
|
+
// Refusals in a row: a push that is only waiting proves nothing yet, and
|
|
1554
|
+
// nothing left to push ends the series.
|
|
1555
|
+
let attempts = 0;
|
|
1556
|
+
if (refused) {
|
|
1557
|
+
attempts = check.attempts + 1;
|
|
1558
|
+
}
|
|
1559
|
+
else if (waiting) {
|
|
1560
|
+
attempts = check.attempts;
|
|
1561
|
+
}
|
|
1562
|
+
if (refused) {
|
|
1563
|
+
consider(at + Math.min(noticeBackoffMs * 2 ** Math.min(attempts - 1, 16), dayMs));
|
|
1564
|
+
}
|
|
1565
|
+
const item = {
|
|
1566
|
+
kind: 'notices',
|
|
1567
|
+
attempts,
|
|
1568
|
+
...(stillFailed.length === 0 ? {} : { failed: stillFailed }),
|
|
1569
|
+
...(pushed.length === 0 ? {} : { pushed }),
|
|
1570
|
+
};
|
|
1571
|
+
// With nothing to look at again, a check that remembers failed pushes
|
|
1572
|
+
// stays without a due time, so they still fail once; a new record makes
|
|
1573
|
+
// it due again.
|
|
1574
|
+
let settled;
|
|
1575
|
+
if (next !== undefined) {
|
|
1576
|
+
settled = { player, id: noticesEntryId, dueAt: formatNotificationInstant(next), item };
|
|
1577
|
+
}
|
|
1578
|
+
else if (stillFailed.length > 0) {
|
|
1579
|
+
settled = { player, id: noticesEntryId, item };
|
|
1580
|
+
}
|
|
1581
|
+
// A record written since the check began made it due again: that stands.
|
|
1582
|
+
await store.updateScheduleEntry({
|
|
1583
|
+
player,
|
|
1584
|
+
id: noticesEntryId,
|
|
1585
|
+
version: held.version,
|
|
1586
|
+
entry: settled,
|
|
1587
|
+
});
|
|
1588
|
+
return results;
|
|
1589
|
+
};
|
|
1590
|
+
/**
|
|
1591
|
+
* Makes the player's notice check due at `at`, and clears its backoff,
|
|
1592
|
+
* always with a new version. With `onlyBackedOff`, it acts only on a check
|
|
1593
|
+
* that is backing off after a refused push (a new registration may let
|
|
1594
|
+
* the push through).
|
|
1595
|
+
*/
|
|
1596
|
+
const markNoticesDue = async (player, at, onlyBackedOff = false) => {
|
|
1597
|
+
if (!carriesNotices()) {
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
for (let attempt = 0; attempt < maxLedgerAttempts; attempt += 1) {
|
|
1601
|
+
const held = await store.getScheduleEntry({ player, id: noticesEntryId });
|
|
1602
|
+
const check = held?.entry.item.kind === 'notices'
|
|
1603
|
+
? held.entry.item
|
|
1604
|
+
: { kind: 'notices', attempts: 0 };
|
|
1605
|
+
if (onlyBackedOff && (held === undefined || check.attempts === 0)) {
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
const dueAt = held?.entry.dueAt === undefined
|
|
1609
|
+
? at
|
|
1610
|
+
: Math.min(Date.parse(held.entry.dueAt), at);
|
|
1611
|
+
// Written even when the check is already due: the new version makes a
|
|
1612
|
+
// run that read the history before this act lose its settle, so the
|
|
1613
|
+
// act's notice is looked at again. A new act or registration starts
|
|
1614
|
+
// over: the backoff ends, and the pushes of the last check are not
|
|
1615
|
+
// read as refused.
|
|
1616
|
+
const { pushed: _pushed, ...kept } = check;
|
|
1617
|
+
const entry = {
|
|
1618
|
+
player,
|
|
1619
|
+
id: noticesEntryId,
|
|
1620
|
+
dueAt: formatNotificationInstant(dueAt),
|
|
1621
|
+
item: { ...kept, attempts: 0 },
|
|
1622
|
+
};
|
|
1623
|
+
if (held === undefined
|
|
1624
|
+
? (await store.createScheduleEntry(entry)) !== undefined
|
|
1625
|
+
: await store.updateScheduleEntry({ player, id: noticesEntryId, version: held.version, entry })) {
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
throw busy(noticesEntryId);
|
|
1630
|
+
};
|
|
1631
|
+
/**
|
|
1632
|
+
* Deletes the records no rule reads any more, once they are a day old (the
|
|
1633
|
+
* daily cap still counts younger ones): every record but the latest of its
|
|
1634
|
+
* kind and scope, except, under the Korean rules, an advertising or night
|
|
1635
|
+
* record whose result notice is still owed and the record of its kind
|
|
1636
|
+
* before it, which decides whether it reports a refusal. The latest
|
|
1637
|
+
* consent of a kind is the latest record of it, so re-confirmations keep
|
|
1638
|
+
* their record. A failure is reported; the history only stays longer.
|
|
1639
|
+
*/
|
|
1640
|
+
const compactConsent = async (player, records, at) => {
|
|
1641
|
+
try {
|
|
1642
|
+
const keep = new Set();
|
|
1643
|
+
const latest = new Map();
|
|
1644
|
+
for (const record of records) {
|
|
1645
|
+
latest.set(JSON.stringify([record.kind, record.topic ?? null]), record);
|
|
1646
|
+
}
|
|
1647
|
+
for (const record of latest.values()) {
|
|
1648
|
+
keep.add(Date.parse(record.at));
|
|
1649
|
+
}
|
|
1650
|
+
if (rules === 'kr' && records.some((record) => record.kind === 'advertising' || record.kind === 'night')) {
|
|
1651
|
+
const owed = new Set(listNotificationNotices(records, await store.listNoticesGiven(player), formatNotificationInstant(at))
|
|
1652
|
+
.map((notice) => Date.parse(notice.record.at)));
|
|
1653
|
+
for (const kind of ['advertising', 'night']) {
|
|
1654
|
+
const series = records.filter((record) => record.kind === kind);
|
|
1655
|
+
series.forEach((record, index) => {
|
|
1656
|
+
if (owed.has(Date.parse(record.at))) {
|
|
1657
|
+
keep.add(Date.parse(record.at));
|
|
1658
|
+
const before = series[index - 1];
|
|
1659
|
+
if (before !== undefined) {
|
|
1660
|
+
keep.add(Date.parse(before.at));
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
// A per-send provider counts every accepted prompt (gate step 3), so
|
|
1667
|
+
// while one is registered the acceptance records are never pruned, and
|
|
1668
|
+
// that part of the history grows with every acceptance.
|
|
1669
|
+
// The registry refuses per-send providers today; this keeps a
|
|
1670
|
+
// registry that serves one from losing acceptances silently.
|
|
1671
|
+
const perSend = registry.providers.some((provider) => provider.entry.permission.allowance === 'per-send');
|
|
1672
|
+
const prunable = records.filter((record) => Date.parse(record.at) <= at - consentCompactionMs
|
|
1673
|
+
&& !keep.has(Date.parse(record.at))
|
|
1674
|
+
&& !(perSend && (record.kind === 'delivery-permission' || record.kind === 'host-agreement')));
|
|
1675
|
+
if (prunable.length > 0) {
|
|
1676
|
+
await store.pruneConsent({ player, at: prunable.map((record) => record.at) });
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
catch (error) {
|
|
1680
|
+
report(error, 'consent.compact');
|
|
1681
|
+
}
|
|
1682
|
+
};
|
|
1683
|
+
/**
|
|
1684
|
+
* Makes the player's notice check due now, best effort: after a
|
|
1685
|
+
* registration only a check backing off after a refused push (the new
|
|
1686
|
+
* device may let it through), after a consent write any.
|
|
1687
|
+
*/
|
|
1688
|
+
const retryNoticePushes = async (player, onlyBackedOff = true, phase = 'registration') => {
|
|
1689
|
+
try {
|
|
1690
|
+
await markNoticesDue(player, nowMs(), onlyBackedOff);
|
|
1691
|
+
}
|
|
1692
|
+
catch (error) {
|
|
1693
|
+
report(error, phase === 'registration' ? 'notices.retry' : `notices.retry.${phase}`);
|
|
1694
|
+
}
|
|
1695
|
+
};
|
|
1696
|
+
/**
|
|
1697
|
+
* One round's schedule entries: up to `limit` due ones, each fired,
|
|
1698
|
+
* checked, or forgotten, with each send it touched. A schedule that cannot
|
|
1699
|
+
* be read is counted and reported, and the round's due rows still go out.
|
|
1700
|
+
*/
|
|
1701
|
+
const runScheduleEntries = async (limit, touched) => {
|
|
1702
|
+
let failed = 0;
|
|
1703
|
+
let due = [];
|
|
1704
|
+
try {
|
|
1705
|
+
due = await store.listDueScheduleEntries({ at: formatNotificationInstant(nowMs()), limit });
|
|
1706
|
+
}
|
|
1707
|
+
catch (error) {
|
|
1708
|
+
// The due rows still go out: a schedule that cannot be read must not hold them.
|
|
1709
|
+
failed += 1;
|
|
1710
|
+
report(error, 'scheduler.list');
|
|
1711
|
+
}
|
|
1712
|
+
let handled = 0;
|
|
1713
|
+
for (const { player, id } of due) {
|
|
1714
|
+
let held;
|
|
1715
|
+
try {
|
|
1716
|
+
held = await store.getScheduleEntry({ player, id });
|
|
1717
|
+
if (held?.entry.dueAt === undefined || Date.parse(held.entry.dueAt) > nowMs()) {
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
const { item } = held.entry;
|
|
1721
|
+
if (item.kind === 'notices') {
|
|
1722
|
+
for (const result of await checkNotices(held, item)) {
|
|
1723
|
+
touched.set(result.sendKey, result.outcome);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
else if (item.firedAt !== undefined) {
|
|
1727
|
+
await forgetFiredReminder(held, item);
|
|
1728
|
+
}
|
|
1729
|
+
else {
|
|
1730
|
+
const result = await fireReminder(held, item);
|
|
1731
|
+
if (result === 'busy') {
|
|
1732
|
+
// Another run holds its send: it waits a minute, so the next
|
|
1733
|
+
// round does not list it again, and it is not progress. The run
|
|
1734
|
+
// holding the send then loses its mark to this version, re-reads
|
|
1735
|
+
// the same reminder, and keeps its rows; the next run marks it.
|
|
1736
|
+
await deferEntry(held, nowMs());
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
if (result === 'stale') {
|
|
1740
|
+
// A set replaced it since the read: the newer entry stands.
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1743
|
+
if (result !== undefined) {
|
|
1744
|
+
touched.set(result.sendKey, result.outcome);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
// Counted once the entry was worked: a failing one is not progress.
|
|
1748
|
+
handled += 1;
|
|
1749
|
+
}
|
|
1750
|
+
catch (error) {
|
|
1751
|
+
failed += 1;
|
|
1752
|
+
report(error, 'scheduler', `${encodeURIComponent(player)}:${id}`);
|
|
1753
|
+
// A failure that may pass waits a minute; one that already moved or
|
|
1754
|
+
// removed the entry (its version changed) is left as it is.
|
|
1755
|
+
if (held !== undefined && !(error instanceof NotificationError && error.code === 'NOTIFICATIONS_INVALID_INPUT')) {
|
|
1756
|
+
await deferEntry(held, nowMs());
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
return { listed: due.length, handled, failed };
|
|
1761
|
+
};
|
|
1762
|
+
/**
|
|
1763
|
+
* Each authenticated call carries the session's current end, and the
|
|
1764
|
+
* endpoints the session registered move to it when it is later, so a
|
|
1765
|
+
* session the identity service extended keeps its devices bound without a
|
|
1766
|
+
* new registration. It never fails the call; a failure is reported. It
|
|
1767
|
+
* runs before a call's own validation on purpose: the session is
|
|
1768
|
+
* authenticated, so even a call refused for its input reports a valid end.
|
|
1769
|
+
*/
|
|
1770
|
+
const authenticated = async (session) => {
|
|
1771
|
+
const current = sessionOf(session);
|
|
1772
|
+
try {
|
|
1773
|
+
await store.extendSessionEndpoints({
|
|
1774
|
+
sessionId: current.sessionId,
|
|
1775
|
+
...(current.expiresAt === undefined ? {} : { expiresAt: normalizeNotificationTimestamp(current.expiresAt) }),
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
catch (error) {
|
|
1779
|
+
report(error, 'endpoints.extend');
|
|
1780
|
+
}
|
|
1781
|
+
return current;
|
|
1782
|
+
};
|
|
1783
|
+
return {
|
|
1784
|
+
providers: () => registry.snapshot(),
|
|
1785
|
+
async recordConsent(session, record) {
|
|
1786
|
+
const { player } = await authenticated(session);
|
|
1787
|
+
if (!isPlainObject(record)) {
|
|
1788
|
+
throw invalidInput('A consent record is an object.');
|
|
1789
|
+
}
|
|
1790
|
+
const at = formatNotificationInstant(nowMs());
|
|
1791
|
+
// The backend's clock dates the record; a client's `at` is ignored.
|
|
1792
|
+
const { at: _clientAt, ...fields } = record;
|
|
1793
|
+
let checked;
|
|
1794
|
+
try {
|
|
1795
|
+
checked = assertNotificationConsentRecord({ ...fields, at });
|
|
1796
|
+
}
|
|
1797
|
+
catch (error) {
|
|
1798
|
+
throw invalidInput(`Invalid consent record: ${describe(error)}`, error);
|
|
1799
|
+
}
|
|
1800
|
+
if (checked.source === 'provider') {
|
|
1801
|
+
throw invalidInput('Provider records are written only by the backend from a provider answer.');
|
|
1802
|
+
}
|
|
1803
|
+
if ((checked.topic !== undefined && notificationKeyIssue(checked.topic) !== undefined)
|
|
1804
|
+
|| (checked.textVersion !== undefined && !isBoundedText(checked.textVersion))
|
|
1805
|
+
|| (checked.evidence !== undefined && new TextEncoder().encode(JSON.stringify(checked.evidence)).length > maxEvidenceBytes)) {
|
|
1806
|
+
throw invalidInput('A consent record field is out of bounds.');
|
|
1807
|
+
}
|
|
1808
|
+
const history = await store.listConsent(player);
|
|
1809
|
+
// A repeat of the latest permission, agreement, or preference record
|
|
1810
|
+
// for the kind and topic is not written again: a client that resends
|
|
1811
|
+
// its report changes nothing. Evidence is not compared, so the first
|
|
1812
|
+
// record keeps its own. An `advertising` or `night` record is always
|
|
1813
|
+
// written, since each is a player's act that may owe a notice (a
|
|
1814
|
+
// second refusal, a renewed consent).
|
|
1815
|
+
const latest = checked.kind === 'advertising' || checked.kind === 'night'
|
|
1816
|
+
? undefined
|
|
1817
|
+
: latestNotificationConsent(history, checked.kind, checked.topic);
|
|
1818
|
+
if (latest !== undefined && latest.state === checked.state && latest.source === checked.source) {
|
|
1819
|
+
return latest;
|
|
1820
|
+
}
|
|
1821
|
+
const windowStart = Date.parse(at) - dayMs;
|
|
1822
|
+
const counted = history.filter((entry) => entry.source !== 'provider' && Date.parse(entry.at) > windowStart);
|
|
1823
|
+
const oldest = counted[counted.length - maxClientNotificationConsentRecordsPerDay];
|
|
1824
|
+
if (oldest !== undefined) {
|
|
1825
|
+
throw new NotificationConsentRateLimitError(Math.max(1, Math.ceil((Date.parse(oldest.at) - windowStart) / secondMs)));
|
|
1826
|
+
}
|
|
1827
|
+
const { at: _at, ...withoutAt } = checked;
|
|
1828
|
+
if (checked.kind === 'advertising' || checked.kind === 'night') {
|
|
1829
|
+
// Before the write, and again after it: a check that finds nothing new
|
|
1830
|
+
// is harmless. Best effort, so the player's consent is recorded even
|
|
1831
|
+
// while the schedule cannot be written; the game still lists the
|
|
1832
|
+
// notice in game.
|
|
1833
|
+
await retryNoticePushes(player, false, 'before-consent');
|
|
1834
|
+
}
|
|
1835
|
+
const stored = await store.appendConsent({ player, record: withoutAt, at });
|
|
1836
|
+
if (stored.kind === 'advertising' || stored.kind === 'night') {
|
|
1837
|
+
// Again after the write: a check that ran between the two found nothing new and settled.
|
|
1838
|
+
await retryNoticePushes(player, false, 'after-consent');
|
|
1839
|
+
}
|
|
1840
|
+
await compactConsent(player, [...history, stored], Date.parse(at));
|
|
1841
|
+
return stored;
|
|
1842
|
+
},
|
|
1843
|
+
async listConsent(session) {
|
|
1844
|
+
return store.listConsent((await authenticated(session)).player);
|
|
1845
|
+
},
|
|
1846
|
+
async registerEndpoint(session, request) {
|
|
1847
|
+
const current = await authenticated(session);
|
|
1848
|
+
let registration;
|
|
1849
|
+
try {
|
|
1850
|
+
registration = assertNotificationEndpointRegistration(request.registration);
|
|
1851
|
+
}
|
|
1852
|
+
catch (error) {
|
|
1853
|
+
throw invalidInput(`Invalid registration: ${describe(error)}`, error);
|
|
1854
|
+
}
|
|
1855
|
+
const provider = registry.provider(registration.provider);
|
|
1856
|
+
if (provider === undefined) {
|
|
1857
|
+
throw invalidInput(`This backend has no notifications provider ${registration.provider}.`);
|
|
1858
|
+
}
|
|
1859
|
+
if ((registration.locale !== undefined && !isBoundedText(registration.locale, 64))
|
|
1860
|
+
|| (registration.timeZone !== undefined && !isValidNotificationTimeZone(registration.timeZone))) {
|
|
1861
|
+
throw invalidInput('The registration locale or time zone is not valid.');
|
|
1862
|
+
}
|
|
1863
|
+
const registeredAt = formatNotificationInstant(nowMs());
|
|
1864
|
+
const { address } = registration;
|
|
1865
|
+
if (address.kind === 'platform-user') {
|
|
1866
|
+
if (provider.entry.addressing !== 'platform-user') {
|
|
1867
|
+
throw invalidInput(`Provider ${provider.entry.provider} sends to devices; it needs a device address.`);
|
|
1868
|
+
}
|
|
1869
|
+
await store.putPlatformRegistration({
|
|
1870
|
+
player: current.player,
|
|
1871
|
+
provider: provider.entry.provider,
|
|
1872
|
+
...(registration.locale === undefined ? {} : { locale: registration.locale }),
|
|
1873
|
+
...(registration.timeZone === undefined ? {} : { timeZone: registration.timeZone }),
|
|
1874
|
+
registeredAt,
|
|
1875
|
+
});
|
|
1876
|
+
await retryNoticePushes(current.player);
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
if (provider.entry.addressing !== 'device') {
|
|
1880
|
+
throw invalidInput(`Provider ${provider.entry.provider} resolves platform users; it takes no device address.`);
|
|
1881
|
+
}
|
|
1882
|
+
if (!provider.addressKinds.includes(address.kind)) {
|
|
1883
|
+
// No provider sends to a Firebase installation id yet; the kind is
|
|
1884
|
+
// reserved for when FCM does (RFC "Delivery providers").
|
|
1885
|
+
const reserved = address.kind === 'fcm-installation' && provider.addressKinds.includes('fcm-token')
|
|
1886
|
+
? ` ${fcmInstallationIdRefusal}`
|
|
1887
|
+
: '';
|
|
1888
|
+
throw invalidInput(`Provider ${provider.entry.provider} cannot send to ${address.kind} addresses.${reserved}`);
|
|
1889
|
+
}
|
|
1890
|
+
if (address.value.length > maxAddressLength || hasControlCharacter(address.value)) {
|
|
1891
|
+
throw invalidInput('The device address is out of bounds.');
|
|
1892
|
+
}
|
|
1893
|
+
if (request.target === undefined && provider.targets.length > 1) {
|
|
1894
|
+
throw invalidInput(`Provider ${provider.entry.provider} serves ${provider.targets.join(', ')}; the registration names its target.`);
|
|
1895
|
+
}
|
|
1896
|
+
if (request.target !== undefined && !provider.targets.includes(request.target)) {
|
|
1897
|
+
throw invalidInput(`Provider ${provider.entry.provider} does not serve target ${String(request.target)}.`);
|
|
1898
|
+
}
|
|
1899
|
+
await store.putEndpoint({
|
|
1900
|
+
provider: provider.entry.provider,
|
|
1901
|
+
installationId: current.installationId,
|
|
1902
|
+
player: current.player,
|
|
1903
|
+
address: {
|
|
1904
|
+
kind: address.kind,
|
|
1905
|
+
value: address.value,
|
|
1906
|
+
...(address.environment === undefined ? {} : { environment: address.environment }),
|
|
1907
|
+
},
|
|
1908
|
+
permission: address.permission,
|
|
1909
|
+
...(registration.locale === undefined ? {} : { locale: registration.locale }),
|
|
1910
|
+
...(registration.timeZone === undefined ? {} : { timeZone: registration.timeZone }),
|
|
1911
|
+
...(request.target === undefined ? {} : { target: request.target }),
|
|
1912
|
+
sessionId: current.sessionId,
|
|
1913
|
+
...(current.expiresAt === undefined ? {} : { sessionExpiresAt: normalizeNotificationTimestamp(current.expiresAt) }),
|
|
1914
|
+
lastRegisteredAt: registeredAt,
|
|
1915
|
+
});
|
|
1916
|
+
await retryNoticePushes(current.player);
|
|
1917
|
+
},
|
|
1918
|
+
async unregisterEndpoint(session, request = {}) {
|
|
1919
|
+
const current = await authenticated(session);
|
|
1920
|
+
if (request.provider !== undefined && !isBoundedText(request.provider)) {
|
|
1921
|
+
throw invalidInput('provider: expected a non-empty string.');
|
|
1922
|
+
}
|
|
1923
|
+
await store.removeEndpoints({
|
|
1924
|
+
installationId: current.installationId,
|
|
1925
|
+
player: current.player,
|
|
1926
|
+
...(request.provider === undefined ? {} : { provider: request.provider }),
|
|
1927
|
+
});
|
|
1928
|
+
},
|
|
1929
|
+
async endSession({ sessionId }) {
|
|
1930
|
+
if (!isBoundedText(sessionId)) {
|
|
1931
|
+
throw invalidInput('sessionId: expected a non-empty string.');
|
|
1932
|
+
}
|
|
1933
|
+
await store.removeSessionEndpoints({ sessionId });
|
|
1934
|
+
},
|
|
1935
|
+
async listNotices(session) {
|
|
1936
|
+
return noticesOf((await authenticated(session)).player, nowMs());
|
|
1937
|
+
},
|
|
1938
|
+
async recordNoticeShown(session, notice, options = {}) {
|
|
1939
|
+
const { player } = await authenticated(session);
|
|
1940
|
+
let named;
|
|
1941
|
+
try {
|
|
1942
|
+
const parsed = assertRecordNotificationNoticeInput(notice);
|
|
1943
|
+
named = {
|
|
1944
|
+
kind: parsed.kind,
|
|
1945
|
+
record: { kind: parsed.record.kind, at: normalizeNotificationTimestamp(parsed.record.at) },
|
|
1946
|
+
dueBy: parsed.dueBy,
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
catch (error) {
|
|
1950
|
+
throw invalidInput('A notice is a listed notice, or its kind, the kind and time of the record it reports on, '
|
|
1951
|
+
+ `and its dueBy: ${describe(error)}`, error);
|
|
1952
|
+
}
|
|
1953
|
+
const channel = options.channel === undefined ? 'in-game' : options.channel;
|
|
1954
|
+
// Only the backend records a push, once the push is delivered.
|
|
1955
|
+
if (channel !== 'in-game' && channel !== 'other') {
|
|
1956
|
+
throw invalidInput('channel: a game records a notice it showed, in-game or other.');
|
|
1957
|
+
}
|
|
1958
|
+
const at = nowMs();
|
|
1959
|
+
// Only a listed notice is recorded: one already recorded, not listed
|
|
1960
|
+
// yet, or unknown changes nothing, so a retried call is safe.
|
|
1961
|
+
if (!await noticeListed(player, named, at)) {
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
await store.addNoticeGiven({ ...named, player, channel, at: formatNotificationInstant(at) });
|
|
1965
|
+
},
|
|
1966
|
+
async deliver(raw) {
|
|
1967
|
+
const request = sendRequestOf(raw);
|
|
1968
|
+
refuseNoticeTopic(request.topic);
|
|
1969
|
+
const fingerprint = notificationSendFingerprint(request);
|
|
1970
|
+
const key = request.idempotencyKey;
|
|
1971
|
+
for (let attempt = 0; attempt < maxLedgerAttempts; attempt += 1) {
|
|
1972
|
+
const at = nowMs();
|
|
1973
|
+
const held = await lookup(key, at);
|
|
1974
|
+
if (held !== undefined) {
|
|
1975
|
+
if (held.send.fingerprint !== fingerprint) {
|
|
1976
|
+
throw new NotificationError('NOTIFICATIONS_IDEMPOTENCY_CONFLICT', {
|
|
1977
|
+
message: `Key ${key} is stored for another request.`,
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
const { send } = held;
|
|
1981
|
+
if (send.rows.length === 0) {
|
|
1982
|
+
if (send.claim !== undefined && Date.parse(send.claim.leaseUntil) > at) {
|
|
1983
|
+
return { status: 'in-flight' };
|
|
1984
|
+
}
|
|
1985
|
+
// An evaluation that never finished, or an empty row set: start over.
|
|
1986
|
+
await store.updateSend({ key, version: held.version, send: undefined });
|
|
1987
|
+
continue;
|
|
1988
|
+
}
|
|
1989
|
+
const expired = send.rows.filter((row) => eligible('retry', row, at));
|
|
1990
|
+
if (expired.length > 0) {
|
|
1991
|
+
// A platform row's lease expired: this caller is the next claimer and replays it.
|
|
1992
|
+
const wasDelivered = notificationSendOutcome(send)?.status === 'delivered';
|
|
1993
|
+
const { refusals } = await runInline(key, expired, 'retry');
|
|
1994
|
+
const settled = await settle(key, refusals);
|
|
1995
|
+
return wasDelivered && settled.status === 'delivered'
|
|
1996
|
+
? { status: 'delivered', alreadyProcessed: true }
|
|
1997
|
+
: settled;
|
|
1998
|
+
}
|
|
1999
|
+
const stored = notificationSendOutcome(send) ?? noEndpoint();
|
|
2000
|
+
return stored.status === 'delivered' ? { status: 'delivered', alreadyProcessed: true } : stored;
|
|
2001
|
+
}
|
|
2002
|
+
const token = newToken();
|
|
2003
|
+
const send = {
|
|
2004
|
+
key,
|
|
2005
|
+
fingerprint,
|
|
2006
|
+
request,
|
|
2007
|
+
origin: { kind: 'send' },
|
|
2008
|
+
scheduled: false,
|
|
2009
|
+
rows: [],
|
|
2010
|
+
claim: { token, leaseUntil: formatNotificationInstant(at + leaseMs) },
|
|
2011
|
+
changedAt: formatNotificationInstant(at),
|
|
2012
|
+
};
|
|
2013
|
+
const version = await store.createSend(send);
|
|
2014
|
+
if (version !== undefined) {
|
|
2015
|
+
return sendNew(send, version, at);
|
|
2016
|
+
}
|
|
2017
|
+
// A concurrent send created the key first: answer from its state.
|
|
2018
|
+
}
|
|
2019
|
+
throw busy(key);
|
|
2020
|
+
},
|
|
2021
|
+
async dispatch(job) {
|
|
2022
|
+
if (!isPlainObject(job)
|
|
2023
|
+
|| !isBoundedText(job.sendKey)
|
|
2024
|
+
|| !isBoundedText(job.provider)
|
|
2025
|
+
|| !isBoundedText(job.rowKey, maxRowKeyLength)) {
|
|
2026
|
+
throw invalidInput('A dispatch job names its send key, provider, and row key.');
|
|
2027
|
+
}
|
|
2028
|
+
await runRow(job.sendKey, job.provider, job.rowKey, 'job');
|
|
2029
|
+
},
|
|
2030
|
+
async dispatchDue(options = {}) {
|
|
2031
|
+
const { results, failed } = await dispatchDueRows(options);
|
|
2032
|
+
return { results, failed };
|
|
2033
|
+
},
|
|
2034
|
+
async setReminder(session, raw) {
|
|
2035
|
+
const { player } = await authenticated(session);
|
|
2036
|
+
const reminder = reminderOf(raw);
|
|
2037
|
+
refuseNoticeTopic(reminder.topic);
|
|
2038
|
+
if (Date.parse(reminder.fireAt) - nowMs() > notificationReminderHorizonDays * dayMs) {
|
|
2039
|
+
throw invalidInput(`Invalid reminder: fireAt is more than ${notificationReminderHorizonDays} days ahead.`);
|
|
2040
|
+
}
|
|
2041
|
+
let sendKey;
|
|
2042
|
+
try {
|
|
2043
|
+
sendKey = createNotificationReminderIdempotencyKey({ player, key: reminder.key, fireAt: reminder.fireAt });
|
|
2044
|
+
}
|
|
2045
|
+
catch (error) {
|
|
2046
|
+
throw invalidInput(`Invalid reminder: ${describe(error)}`, error);
|
|
2047
|
+
}
|
|
2048
|
+
const origin = { kind: 'reminder', key: reminder.key };
|
|
2049
|
+
// The providers that would deliver the topic remotely, for some target they serve.
|
|
2050
|
+
const declaring = registry.providers.filter((provider) => provider.targets.some((target) => catalog.topicsFor(target).some((topic) => topic.id === reminder.topic && topic.channels.includes('remote'))));
|
|
2051
|
+
if (declaring.length === 0) {
|
|
2052
|
+
throw invalidInput(`Topic ${reminder.topic} is not delivered remotely by this backend.`);
|
|
2053
|
+
}
|
|
2054
|
+
const advertising = declaring.some((provider) => topicFor(provider, undefined, reminder.topic, origin)?.purpose === 'advertising');
|
|
2055
|
+
if (advertising && deployment?.advertisingRules === undefined) {
|
|
2056
|
+
// The gate would refuse every send of it, so it is refused now.
|
|
2057
|
+
throw invalidInput(`Advertising topic ${reminder.topic} needs the deployment's advertisingRules.`);
|
|
2058
|
+
}
|
|
2059
|
+
// The same request the scheduler makes, so their fingerprints compare.
|
|
2060
|
+
const request = reminderRequest(player, reminder);
|
|
2061
|
+
const at = nowMs();
|
|
2062
|
+
// Where the player can be reached now, else every declaring provider unresolved.
|
|
2063
|
+
const reached = (await recipientsOf(request, at, origin)).filter((recipient) => recipient.resolved && declaring.includes(recipient.provider));
|
|
2064
|
+
const candidates = reached.length > 0
|
|
2065
|
+
? reached
|
|
2066
|
+
: declaring.map((provider) => ({ provider, rowKey: sendKey, resolved: false }));
|
|
2067
|
+
// Any declaring host-template provider that could deliver the topic,
|
|
2068
|
+
// reached now or not: the player may become reachable through it before
|
|
2069
|
+
// the reminder fires, and it would take the reminder's templateData as
|
|
2070
|
+
// its template context. One whose purposes leave the topic out (or an
|
|
2071
|
+
// advertising topic, free-form only) is refused by the gate's first
|
|
2072
|
+
// step; the catalog validator refuses such a mapping, so this matters
|
|
2073
|
+
// only for a hand-built service catalog.
|
|
2074
|
+
const hostTemplate = declaring.some((provider) => {
|
|
2075
|
+
if (provider.entry.content !== 'host-template') {
|
|
2076
|
+
return false;
|
|
2077
|
+
}
|
|
2078
|
+
const purpose = topicFor(provider, undefined, reminder.topic, origin)?.purpose;
|
|
2079
|
+
return purpose !== undefined && purpose !== 'advertising' && provider.entry.purposes.includes(purpose);
|
|
2080
|
+
});
|
|
2081
|
+
if (reminder.templateData !== undefined && hostTemplate) {
|
|
2082
|
+
throw invalidInput('A host-template provider keeps template context on the server; a reminder takes no templateData.');
|
|
2083
|
+
}
|
|
2084
|
+
// Gate steps 3 to 5 at set time: one candidate that passes is enough.
|
|
2085
|
+
const consent = await store.listConsent(player);
|
|
2086
|
+
const permissions = new Map();
|
|
2087
|
+
let refusal;
|
|
2088
|
+
let passed = false;
|
|
2089
|
+
let mapped = false;
|
|
2090
|
+
let linkRefusal;
|
|
2091
|
+
for (const recipient of candidates) {
|
|
2092
|
+
const topic = topicFor(recipient.provider, recipient.target, reminder.topic, origin);
|
|
2093
|
+
if (topic === undefined || !topic.channels.includes('remote')) {
|
|
2094
|
+
continue;
|
|
2095
|
+
}
|
|
2096
|
+
mapped = true;
|
|
2097
|
+
// The gate's link step, at set time: a provider whose sends need a
|
|
2098
|
+
// link takes the reminder's (by its mapping rule) or the mapping's.
|
|
2099
|
+
const linkIssue = reminderLinkIssue(recipient.provider, topic, reminder.link);
|
|
2100
|
+
if (linkIssue !== undefined) {
|
|
2101
|
+
linkRefusal ??= linkIssue;
|
|
2102
|
+
continue;
|
|
2103
|
+
}
|
|
2104
|
+
const id = recipient.provider.entry.provider;
|
|
2105
|
+
let permission = permissions.get(id);
|
|
2106
|
+
if (permission === undefined) {
|
|
2107
|
+
permission = readServerPermission(recipient.provider, request);
|
|
2108
|
+
permissions.set(id, permission);
|
|
2109
|
+
}
|
|
2110
|
+
const serverPermission = await permission;
|
|
2111
|
+
const refused = evaluateNotificationReminderConsent({
|
|
2112
|
+
entry: recipient.provider.entry,
|
|
2113
|
+
topic,
|
|
2114
|
+
consent,
|
|
2115
|
+
...(serverPermission === undefined ? {} : { serverPermission }),
|
|
2116
|
+
...(recipient.endpoint === undefined ? {} : { endpointPermission: recipient.endpoint.permission }),
|
|
2117
|
+
history: [],
|
|
2118
|
+
});
|
|
2119
|
+
if (refused === undefined) {
|
|
2120
|
+
passed = true;
|
|
2121
|
+
break;
|
|
2122
|
+
}
|
|
2123
|
+
if (refusal === undefined || reminderRefusalRank[refused] > reminderRefusalRank[refusal]) {
|
|
2124
|
+
refusal = refused;
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
if (!passed) {
|
|
2128
|
+
if (refusal !== undefined) {
|
|
2129
|
+
return { status: refusal };
|
|
2130
|
+
}
|
|
2131
|
+
if (linkRefusal !== undefined) {
|
|
2132
|
+
throw invalidInput(`Invalid reminder: ${linkRefusal}`);
|
|
2133
|
+
}
|
|
2134
|
+
// Mapped for none of the player's targets (or none reached): nothing could deliver it.
|
|
2135
|
+
return { status: mapped ? 'permission-required' : 'unavailable' };
|
|
2136
|
+
}
|
|
2137
|
+
// One reminder per key: setting it replaces the previous one and, before
|
|
2138
|
+
// the write, stops the previous one's send that waits; a scheduler run
|
|
2139
|
+
// that fires the previous one meanwhile then fails to mark it and stops
|
|
2140
|
+
// its own send. A previous send of this reminder's content at its key
|
|
2141
|
+
// (an identical re-set) serves it and stays. A stop that fails fails the
|
|
2142
|
+
// set before anything is written, so the set is retried as a whole.
|
|
2143
|
+
const id = reminderEntryId(reminder.key);
|
|
2144
|
+
// A reminder set for a time already past is due now, not at that time,
|
|
2145
|
+
// so a burst of past-dated reminders queues behind those already due.
|
|
2146
|
+
const entry = {
|
|
2147
|
+
player,
|
|
2148
|
+
id,
|
|
2149
|
+
dueAt: formatNotificationInstant(Math.max(Date.parse(reminder.fireAt), at)),
|
|
2150
|
+
item: reminder,
|
|
2151
|
+
};
|
|
2152
|
+
const fingerprint = notificationSendFingerprint(request);
|
|
2153
|
+
let stopped = false;
|
|
2154
|
+
for (let attempt = 0; attempt < maxLedgerAttempts; attempt += 1) {
|
|
2155
|
+
const held = await store.getScheduleEntry({ player, id });
|
|
2156
|
+
const previous = held?.entry.item.kind === 'reminder' ? held.entry.item : undefined;
|
|
2157
|
+
// Replacing a pending reminder is always allowed; a new one counts against the cap.
|
|
2158
|
+
if ((previous === undefined || previous.firedAt !== undefined)
|
|
2159
|
+
&& await store.countPendingReminders(player) >= maxPendingNotificationReminders) {
|
|
2160
|
+
return { status: 'capacity-exceeded' };
|
|
2161
|
+
}
|
|
2162
|
+
if (previous !== undefined) {
|
|
2163
|
+
const previousFingerprint = reminderFingerprint(player, previous);
|
|
2164
|
+
const identical = previous.fireAt === reminder.fireAt && previousFingerprint === fingerprint;
|
|
2165
|
+
if (!identical) {
|
|
2166
|
+
stopped = (await stopReminderSend(player, previous, previousFingerprint)) || stopped;
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
const written = held === undefined
|
|
2170
|
+
? (await store.createScheduleEntry(entry)) !== undefined
|
|
2171
|
+
: await store.updateScheduleEntry({ player, id, version: held.version, entry });
|
|
2172
|
+
if (written) {
|
|
2173
|
+
const pending = previous !== undefined && previous.firedAt === undefined;
|
|
2174
|
+
return { status: pending || stopped ? 'replaced' : 'scheduled' };
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
throw busy(id);
|
|
2178
|
+
},
|
|
2179
|
+
async cancelReminder(session, raw) {
|
|
2180
|
+
const { player } = await authenticated(session);
|
|
2181
|
+
if (!isPlainObject(raw) || Object.keys(raw).some((name) => name !== 'key') || typeof raw.key !== 'string') {
|
|
2182
|
+
throw invalidInput('A reminder cancel names only its key.');
|
|
2183
|
+
}
|
|
2184
|
+
const issue = notificationKeyIssue(raw.key);
|
|
2185
|
+
if (issue !== undefined) {
|
|
2186
|
+
throw invalidInput(`key: ${issue}.`);
|
|
2187
|
+
}
|
|
2188
|
+
const id = reminderEntryId(raw.key);
|
|
2189
|
+
let stopped = false;
|
|
2190
|
+
for (let attempt = 0; attempt < maxLedgerAttempts; attempt += 1) {
|
|
2191
|
+
const held = await store.getScheduleEntry({ player, id });
|
|
2192
|
+
if (held === undefined || held.entry.item.kind !== 'reminder') {
|
|
2193
|
+
return { cancelled: stopped };
|
|
2194
|
+
}
|
|
2195
|
+
const reminder = held.entry.item;
|
|
2196
|
+
const fingerprint = reminderFingerprint(player, reminder);
|
|
2197
|
+
// Before the removal, so a failure leaves the cancel to retry; and
|
|
2198
|
+
// after it, best effort, for a scheduler run that stored the send in
|
|
2199
|
+
// between (its own mark then fails and stops it too). Only this
|
|
2200
|
+
// reminder's content is stopped.
|
|
2201
|
+
stopped = (await stopReminderSend(player, reminder, fingerprint)) || stopped;
|
|
2202
|
+
if (await removeEntry(held)) {
|
|
2203
|
+
try {
|
|
2204
|
+
stopped = (await stopReminderSend(player, reminder, fingerprint)) || stopped;
|
|
2205
|
+
}
|
|
2206
|
+
catch (error) {
|
|
2207
|
+
report(error, 'reminders.stop');
|
|
2208
|
+
}
|
|
2209
|
+
return { cancelled: reminder.firedAt === undefined || stopped };
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
throw busy(id);
|
|
2213
|
+
},
|
|
2214
|
+
async runScheduler(options = {}) {
|
|
2215
|
+
const limit = batchLimit(options.limit);
|
|
2216
|
+
const budgetMs = typeof options.budgetMs === 'number' && Number.isFinite(options.budgetMs) && options.budgetMs > 0
|
|
2217
|
+
? options.budgetMs
|
|
2218
|
+
: defaultSchedulerBudgetMs;
|
|
2219
|
+
const elapsed = input.elapsedMs ?? (() => globalThis.performance.now());
|
|
2220
|
+
const startedAt = elapsed();
|
|
2221
|
+
const touched = new Map();
|
|
2222
|
+
// One pace for the whole run: rows taken in an earlier round count.
|
|
2223
|
+
const pace = paceBudgets();
|
|
2224
|
+
let failed = 0;
|
|
2225
|
+
for (;;) {
|
|
2226
|
+
let more;
|
|
2227
|
+
let progressed;
|
|
2228
|
+
try {
|
|
2229
|
+
const entries = await runScheduleEntries(limit, touched);
|
|
2230
|
+
failed += entries.failed;
|
|
2231
|
+
const dispatched = await dispatchDueRows({ limit }, pace);
|
|
2232
|
+
failed += dispatched.failed;
|
|
2233
|
+
for (const result of dispatched.results) {
|
|
2234
|
+
touched.set(result.sendKey, result.outcome);
|
|
2235
|
+
}
|
|
2236
|
+
// Another round only while one may be due (a batch came back
|
|
2237
|
+
// full, and not only with sends the pace holds back) and this
|
|
2238
|
+
// round got somewhere. A schedule that cannot be read lists
|
|
2239
|
+
// nothing, so its batch is never full.
|
|
2240
|
+
more = entries.listed === limit
|
|
2241
|
+
|| (dispatched.listed === limit && dispatched.paced < dispatched.listed);
|
|
2242
|
+
progressed = entries.handled > 0 || dispatched.results.length > 0;
|
|
2243
|
+
}
|
|
2244
|
+
catch (error) {
|
|
2245
|
+
// A round that fails keeps what earlier rounds did.
|
|
2246
|
+
failed += 1;
|
|
2247
|
+
report(error, 'scheduler.round');
|
|
2248
|
+
break;
|
|
2249
|
+
}
|
|
2250
|
+
if (!more || !progressed || elapsed() - startedAt >= budgetMs) {
|
|
2251
|
+
break;
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
return {
|
|
2255
|
+
results: [...touched].map(([sendKey, outcome]) => ({ sendKey, outcome })),
|
|
2256
|
+
failed,
|
|
2257
|
+
};
|
|
2258
|
+
},
|
|
2259
|
+
async purge() {
|
|
2260
|
+
const at = nowMs();
|
|
2261
|
+
return store.purge({
|
|
2262
|
+
sendsChangedBefore: formatNotificationInstant(at - keyRetentionMs),
|
|
2263
|
+
historyBefore: formatNotificationInstant(at - historyRetentionMs),
|
|
2264
|
+
invalidEndpointsBefore: formatNotificationInstant(at - endpointPurgeDelayMs),
|
|
2265
|
+
staleEndpointsBefore: formatNotificationInstant(at - endpointStaleMs - endpointPurgeDelayMs),
|
|
2266
|
+
sessionEndpointsBefore: formatNotificationInstant(at),
|
|
2267
|
+
});
|
|
2268
|
+
},
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
/**
|
|
2272
|
+
* Why a reminder's `link` cannot reach the player through `provider`, or
|
|
2273
|
+
* `undefined`. A provider whose entry needs a link (Devvit) needs the
|
|
2274
|
+
* reminder's or the topic mapping's, as the gate does at send time
|
|
2275
|
+
* (`link-missing`), and a given link must match the provider's mapping rule
|
|
2276
|
+
* for `link`, so a bad one is refused when it is set rather than spent when
|
|
2277
|
+
* it fires.
|
|
2278
|
+
*/
|
|
2279
|
+
function reminderLinkIssue(provider, topic, link) {
|
|
2280
|
+
if (link !== undefined) {
|
|
2281
|
+
const pattern = provider.validation.mapping['link']?.pattern;
|
|
2282
|
+
if (pattern !== undefined) {
|
|
2283
|
+
let rule;
|
|
2284
|
+
try {
|
|
2285
|
+
rule = new RegExp(pattern, 'u');
|
|
2286
|
+
}
|
|
2287
|
+
catch {
|
|
2288
|
+
return undefined;
|
|
2289
|
+
}
|
|
2290
|
+
if (!rule.test(link)) {
|
|
2291
|
+
return `link does not match provider ${provider.entry.provider}'s link rule.`;
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
return undefined;
|
|
2295
|
+
}
|
|
2296
|
+
if (provider.entry.link && typeof topic.mapping?.['link'] !== 'string') {
|
|
2297
|
+
return `provider ${provider.entry.provider} sends need a link, and neither the reminder nor the topic mapping has one.`;
|
|
2298
|
+
}
|
|
2299
|
+
return undefined;
|
|
2300
|
+
}
|
|
2301
|
+
/** A scheduler batch: clamped to 1 to 1000; anything that is not a number is the default, 100. */
|
|
2302
|
+
function batchLimit(limit) {
|
|
2303
|
+
const asked = typeof limit === 'number' && Number.isFinite(limit) ? Math.floor(limit) : 100;
|
|
2304
|
+
return Math.min(Math.max(asked, 1), 1000);
|
|
2305
|
+
}
|
|
2306
|
+
/**
|
|
2307
|
+
* A due row taken for the queue: queued (in flight, no holder) for one claim
|
|
2308
|
+
* lease. An attempted row stays attempted, and its lease ends before its
|
|
2309
|
+
* replay window does, so the take alone never abandons it.
|
|
2310
|
+
*/
|
|
2311
|
+
function takenRow(row, at, leaseMs) {
|
|
2312
|
+
const leaseUntil = at + leaseMs;
|
|
2313
|
+
if (row.state.status !== 'in-flight' || !row.state.attempted) {
|
|
2314
|
+
return {
|
|
2315
|
+
...row,
|
|
2316
|
+
state: {
|
|
2317
|
+
status: 'in-flight',
|
|
2318
|
+
leaseUntil: formatNotificationInstant(leaseUntil),
|
|
2319
|
+
attempted: false,
|
|
2320
|
+
},
|
|
2321
|
+
};
|
|
2322
|
+
}
|
|
2323
|
+
const windowEnd = row.firstAttemptedAt === undefined
|
|
2324
|
+
? Number.POSITIVE_INFINITY
|
|
2325
|
+
: Date.parse(row.firstAttemptedAt) + ambiguousReplayMs - 1;
|
|
2326
|
+
return {
|
|
2327
|
+
...row,
|
|
2328
|
+
state: {
|
|
2329
|
+
status: 'in-flight',
|
|
2330
|
+
leaseUntil: formatNotificationInstant(windowEnd > at ? Math.min(leaseUntil, windowEnd) : leaseUntil),
|
|
2331
|
+
attempted: true,
|
|
2332
|
+
...(row.state.attemptedAt === undefined ? {} : { attemptedAt: row.state.attemptedAt }),
|
|
2333
|
+
},
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
/** A well-formed server reminder, with `fireAt` in the canonical form, or `NOTIFICATIONS_INVALID_INPUT`. */
|
|
2337
|
+
function reminderOf(raw) {
|
|
2338
|
+
if (!isPlainObject(raw)) {
|
|
2339
|
+
throw invalidInput('A reminder is an object.');
|
|
2340
|
+
}
|
|
2341
|
+
const known = new Set(['key', 'topic', 'fireAt', 'templateData', 'link']);
|
|
2342
|
+
const unknown = Object.keys(raw).find((name) => !known.has(name));
|
|
2343
|
+
const keyIssue = typeof raw.key === 'string'
|
|
2344
|
+
? notificationKeyIssue(raw.key)
|
|
2345
|
+
: 'expected a string';
|
|
2346
|
+
const dataIssue = raw.templateData === undefined
|
|
2347
|
+
? undefined
|
|
2348
|
+
: notificationTemplateDataIssue(raw.templateData);
|
|
2349
|
+
let issue;
|
|
2350
|
+
if (unknown !== undefined) {
|
|
2351
|
+
issue = `${unknown}: not a reminder field`;
|
|
2352
|
+
}
|
|
2353
|
+
else if (keyIssue !== undefined) {
|
|
2354
|
+
issue = `key: ${keyIssue}`;
|
|
2355
|
+
}
|
|
2356
|
+
else if (!isBoundedText(raw.topic)) {
|
|
2357
|
+
issue = 'topic: expected a non-empty string';
|
|
2358
|
+
}
|
|
2359
|
+
else if (typeof raw.fireAt !== 'string' || raw.fireAt.length > 64 || !isNotificationTimestamp(raw.fireAt)) {
|
|
2360
|
+
issue = 'fireAt: expected RFC 3339 with an explicit offset';
|
|
2361
|
+
}
|
|
2362
|
+
else if (dataIssue !== undefined) {
|
|
2363
|
+
issue = `templateData: ${dataIssue}`;
|
|
2364
|
+
}
|
|
2365
|
+
else if (raw.link !== undefined && !isBoundedText(raw.link)) {
|
|
2366
|
+
issue = 'link: expected a non-empty string';
|
|
2367
|
+
}
|
|
2368
|
+
if (issue !== undefined) {
|
|
2369
|
+
throw invalidInput(`Invalid reminder: ${issue}.`);
|
|
2370
|
+
}
|
|
2371
|
+
const input = raw;
|
|
2372
|
+
return {
|
|
2373
|
+
kind: 'reminder',
|
|
2374
|
+
key: input.key,
|
|
2375
|
+
topic: input.topic,
|
|
2376
|
+
fireAt: normalizeNotificationTimestamp(input.fireAt),
|
|
2377
|
+
...(input.templateData === undefined ? {} : { templateData: input.templateData }),
|
|
2378
|
+
...(input.link === undefined ? {} : { link: input.link }),
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
2381
|
+
function noEndpoint() {
|
|
2382
|
+
return { status: 'unavailable', reason: 'no-endpoint' };
|
|
2383
|
+
}
|
|
2384
|
+
function invalidInput(message, cause) {
|
|
2385
|
+
return new NotificationError('NOTIFICATIONS_INVALID_INPUT', {
|
|
2386
|
+
message,
|
|
2387
|
+
...(cause === undefined ? {} : { cause }),
|
|
2388
|
+
});
|
|
2389
|
+
}
|
|
2390
|
+
function busy(key) {
|
|
2391
|
+
return new NotificationError('NOTIFICATIONS_TEMPORARILY_UNAVAILABLE', {
|
|
2392
|
+
message: `The delivery ledger stayed busy for key ${key}; retry the send.`,
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
2395
|
+
function sessionOf(session) {
|
|
2396
|
+
if (!isPlainObject(session)
|
|
2397
|
+
|| !isBoundedText(session.player)
|
|
2398
|
+
|| !isBoundedText(session.sessionId)
|
|
2399
|
+
|| !isBoundedText(session.installationId)
|
|
2400
|
+
|| (session.expiresAt !== undefined
|
|
2401
|
+
&& (typeof session.expiresAt !== 'string' || !isNotificationTimestamp(session.expiresAt)))) {
|
|
2402
|
+
throw new TypeError('A notifications session names its player, session id, installation id, and an optional RFC 3339 expiresAt.');
|
|
2403
|
+
}
|
|
2404
|
+
return session;
|
|
2405
|
+
}
|
|
2406
|
+
/** A well-formed send request, or `NOTIFICATIONS_INVALID_INPUT`. */
|
|
2407
|
+
function sendRequestOf(raw) {
|
|
2408
|
+
if (!isPlainObject(raw)) {
|
|
2409
|
+
throw invalidInput('A send is an object.');
|
|
2410
|
+
}
|
|
2411
|
+
const known = new Set([
|
|
2412
|
+
'idempotencyKey',
|
|
2413
|
+
'player',
|
|
2414
|
+
'topic',
|
|
2415
|
+
'provider',
|
|
2416
|
+
'templateData',
|
|
2417
|
+
'data',
|
|
2418
|
+
'locale',
|
|
2419
|
+
'link',
|
|
2420
|
+
]);
|
|
2421
|
+
const unknown = Object.keys(raw).find((key) => !known.has(key));
|
|
2422
|
+
const issue = unknown !== undefined ? `${unknown}: not a send field` : sendInputIssue(raw);
|
|
2423
|
+
if (issue !== undefined) {
|
|
2424
|
+
throw invalidInput(`Invalid send: ${issue}.`);
|
|
2425
|
+
}
|
|
2426
|
+
const input = raw;
|
|
2427
|
+
return {
|
|
2428
|
+
idempotencyKey: input.idempotencyKey,
|
|
2429
|
+
player: input.player,
|
|
2430
|
+
topic: input.topic,
|
|
2431
|
+
...(input.provider === undefined ? {} : { provider: input.provider }),
|
|
2432
|
+
...(input.templateData === undefined ? {} : { templateData: input.templateData }),
|
|
2433
|
+
...(input.data === undefined ? {} : { data: input.data }),
|
|
2434
|
+
...(input.locale === undefined ? {} : { locale: input.locale }),
|
|
2435
|
+
...(input.link === undefined ? {} : { link: input.link }),
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
function sendInputIssue(request) {
|
|
2439
|
+
if (typeof request.idempotencyKey !== 'string') {
|
|
2440
|
+
return 'idempotencyKey: expected a string';
|
|
2441
|
+
}
|
|
2442
|
+
const keyIssue = notificationKeyIssue(request.idempotencyKey);
|
|
2443
|
+
if (keyIssue !== undefined) {
|
|
2444
|
+
return `idempotencyKey: ${keyIssue}`;
|
|
2445
|
+
}
|
|
2446
|
+
const reserved = notificationReservedKeyPrefix(request.idempotencyKey);
|
|
2447
|
+
if (reserved !== undefined) {
|
|
2448
|
+
return `idempotencyKey: the ${reserved} prefix is reserved for the scheduler`;
|
|
2449
|
+
}
|
|
2450
|
+
for (const name of ['player', 'topic']) {
|
|
2451
|
+
if (!isBoundedText(request[name])) {
|
|
2452
|
+
return `${name}: expected a non-empty string`;
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
if (request.templateData !== undefined) {
|
|
2456
|
+
const issue = notificationTemplateDataIssue(request.templateData);
|
|
2457
|
+
if (issue !== undefined) {
|
|
2458
|
+
return `templateData: ${issue}`;
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
if (request.data !== undefined) {
|
|
2462
|
+
const issue = notificationTemplateDataIssue(request.data, { stringsOnly: true });
|
|
2463
|
+
if (issue !== undefined) {
|
|
2464
|
+
return `data: ${issue}`;
|
|
2465
|
+
}
|
|
2466
|
+
// An open carries its topic and route in these keys; routing data never overrides them.
|
|
2467
|
+
const reserved = Object.keys(request.data).find((key) => key === 'topic' || key === 'route');
|
|
2468
|
+
if (reserved !== undefined) {
|
|
2469
|
+
return `data: the key ${reserved} is reserved for the open's own ${reserved}`;
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
for (const name of ['provider', 'locale', 'link']) {
|
|
2473
|
+
if (request[name] !== undefined && !isBoundedText(request[name])) {
|
|
2474
|
+
return `${name}: expected a non-empty string`;
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
return undefined;
|
|
2478
|
+
}
|
|
2479
|
+
function isBoundedText(value, maxLength = maxTextLength) {
|
|
2480
|
+
return typeof value === 'string'
|
|
2481
|
+
&& value.length > 0
|
|
2482
|
+
&& value.length <= maxLength
|
|
2483
|
+
&& !hasControlCharacter(value);
|
|
2484
|
+
}
|
|
2485
|
+
function hasControlCharacter(value) {
|
|
2486
|
+
return /[\u0000-\u001f\u007f]/u.test(value);
|
|
2487
|
+
}
|
|
2488
|
+
function isPlainObject(value) {
|
|
2489
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
2490
|
+
}
|
|
2491
|
+
function compareText(left, right) {
|
|
2492
|
+
if (left === right) {
|
|
2493
|
+
return 0;
|
|
2494
|
+
}
|
|
2495
|
+
return left < right ? -1 : 1;
|
|
2496
|
+
}
|
|
2497
|
+
/** Seconds a row waits after its holder failed before the provider call. */
|
|
2498
|
+
const releaseBackoffMs = 60 * secondMs;
|
|
2499
|
+
/**
|
|
2500
|
+
* The gate's history for a recipient on `provider`: each send once, on
|
|
2501
|
+
* `provider` when it reached it there, else at its earliest entry, oldest
|
|
2502
|
+
* first.
|
|
2503
|
+
*/
|
|
2504
|
+
function historyOn(history, provider) {
|
|
2505
|
+
return [...history.values()]
|
|
2506
|
+
.map((entries) => entries.find((entry) => entry.provider === provider)
|
|
2507
|
+
?? [...entries].sort((left, right) => Date.parse(left.at) - Date.parse(right.at))[0])
|
|
2508
|
+
.filter((entry) => entry !== undefined)
|
|
2509
|
+
.sort((left, right) => Date.parse(left.at) - Date.parse(right.at));
|
|
2510
|
+
}
|
|
2511
|
+
/**
|
|
2512
|
+
* The row as it was before a claim, due no sooner than a short backoff from
|
|
2513
|
+
* `at`, but never so late that an ambiguous row's replay window closes on
|
|
2514
|
+
* the backoff alone.
|
|
2515
|
+
*/
|
|
2516
|
+
function backedOff(row, at) {
|
|
2517
|
+
const notBefore = at + releaseBackoffMs;
|
|
2518
|
+
if (row.state.status === 'deferred') {
|
|
2519
|
+
return {
|
|
2520
|
+
...row,
|
|
2521
|
+
state: {
|
|
2522
|
+
...row.state,
|
|
2523
|
+
until: formatNotificationInstant(Math.max(Date.parse(row.state.until), notBefore)),
|
|
2524
|
+
},
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
2527
|
+
if (row.state.status === 'in-flight') {
|
|
2528
|
+
// An attempted row's replays end a day after its first attempt; the
|
|
2529
|
+
// backoff does not close that window early, unless it already closed.
|
|
2530
|
+
const windowEnd = row.state.attempted && row.firstAttemptedAt !== undefined
|
|
2531
|
+
? Date.parse(row.firstAttemptedAt) + ambiguousReplayMs - 1
|
|
2532
|
+
: Number.POSITIVE_INFINITY;
|
|
2533
|
+
const until = windowEnd > at ? Math.min(notBefore, windowEnd) : notBefore;
|
|
2534
|
+
return {
|
|
2535
|
+
...row,
|
|
2536
|
+
state: {
|
|
2537
|
+
...row.state,
|
|
2538
|
+
leaseUntil: formatNotificationInstant(Math.max(Date.parse(row.state.leaseUntil), until)),
|
|
2539
|
+
},
|
|
2540
|
+
};
|
|
2541
|
+
}
|
|
2542
|
+
return row;
|
|
2543
|
+
}
|
|
2544
|
+
/**
|
|
2545
|
+
* The gate's refusals from the steps before the permission step
|
|
2546
|
+
* (`evaluateNotificationDelivery`: topic, channel, purpose, recipient,
|
|
2547
|
+
* link), which a send-time permission read cannot change. Typed by the
|
|
2548
|
+
* gate's reason unions, so a misspelt reason does not compile; a gate that
|
|
2549
|
+
* moved the permission step earlier must update this list (the
|
|
2550
|
+
* permission-order test in service.test.ts covers both sides).
|
|
2551
|
+
*/
|
|
2552
|
+
const earlyGateReasons = new Set(['topic-unknown', 'channel-unsupported', 'purpose-unsupported', 'no-endpoint', 'link-missing']);
|
|
2553
|
+
/** Writes that must not fail a settled delivery are tried this often. */
|
|
2554
|
+
const maxSideWriteAttempts = 3;
|
|
2555
|
+
/** Runs `write` up to `maxSideWriteAttempts` times with a short pause; reports only the last failure and never throws. */
|
|
2556
|
+
async function withRetries(write, onFailure) {
|
|
2557
|
+
for (let attempt = 0; attempt < maxSideWriteAttempts; attempt += 1) {
|
|
2558
|
+
try {
|
|
2559
|
+
await write();
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
catch (error) {
|
|
2563
|
+
if (attempt === maxSideWriteAttempts - 1) {
|
|
2564
|
+
onFailure(error);
|
|
2565
|
+
}
|
|
2566
|
+
else {
|
|
2567
|
+
await pause(25 * (attempt + 1));
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
function pause(ms) {
|
|
2573
|
+
return new Promise((resolve) => {
|
|
2574
|
+
setTimeout(resolve, ms);
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
/** A provider's answer when it is one of the five kinds, else `undefined`. */
|
|
2578
|
+
function deliveryAnswerOf(answer) {
|
|
2579
|
+
if (!isPlainObject(answer)) {
|
|
2580
|
+
return undefined;
|
|
2581
|
+
}
|
|
2582
|
+
switch (answer.status) {
|
|
2583
|
+
case 'accepted':
|
|
2584
|
+
return typeof answer.providerMessageId === 'string'
|
|
2585
|
+
? { status: 'accepted', providerMessageId: answer.providerMessageId }
|
|
2586
|
+
: { status: 'accepted' };
|
|
2587
|
+
case 'unregistered':
|
|
2588
|
+
return { status: 'unregistered' };
|
|
2589
|
+
case 'rate-limited':
|
|
2590
|
+
return typeof answer.retryAfterSeconds === 'number'
|
|
2591
|
+
? { status: 'rate-limited', retryAfterSeconds: answer.retryAfterSeconds }
|
|
2592
|
+
: { status: 'rate-limited' };
|
|
2593
|
+
case 'rejected':
|
|
2594
|
+
if (answer.reason !== 'configuration' && answer.reason !== 'recipient' && answer.reason !== 'agreement') {
|
|
2595
|
+
return undefined;
|
|
2596
|
+
}
|
|
2597
|
+
return typeof answer.providerCode === 'string'
|
|
2598
|
+
? {
|
|
2599
|
+
status: 'rejected',
|
|
2600
|
+
reason: answer.reason,
|
|
2601
|
+
providerCode: answer.providerCode.slice(0, 64),
|
|
2602
|
+
}
|
|
2603
|
+
: { status: 'rejected', reason: answer.reason };
|
|
2604
|
+
case 'ambiguous':
|
|
2605
|
+
return { status: 'ambiguous' };
|
|
2606
|
+
default:
|
|
2607
|
+
return undefined;
|
|
2608
|
+
}
|
|
2609
|
+
}
|