@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.
@@ -0,0 +1,423 @@
1
+ import { type NotificationConsentRecord, type NotificationDeferralReason, type NotificationDeliveryOutcome, type NotificationNoticeIdentity, type NotificationNoticeVariant, type NotificationPermissionState, type NotificationTemplateData } from '@mpgd/platform/notifications';
2
+ import type { NotificationDeviceAddress } from './delivery-provider.js';
3
+ /**
4
+ * Persistence for the notifications service (notifications v2 RFC, "Server"):
5
+ * consent records, the endpoint registry, platform-user registrations, the
6
+ * notices given, the send ledger with its per-recipient rows, the delivery
7
+ * history the gate's limits count, and the scheduler's entries (server
8
+ * reminders and each player's notice check). The service owns every rule; a
9
+ * store only keeps rows and answers the few atomic steps listed on each
10
+ * method. `createInMemoryNotificationStore` is the reference implementation;
11
+ * the game-services Worker ships a D1 one.
12
+ */
13
+ /** Each member of a union without `key`. */
14
+ type OmitEach<Union, Key extends PropertyKey> = Union extends unknown ? Omit<Union, Key> : never;
15
+ /** A consent record before the backend dates it. */
16
+ export type NotificationConsentRecordInput = OmitEach<NotificationConsentRecord, 'at'>;
17
+ /** A device endpoint, keyed by provider and installation. */
18
+ export interface NotificationStoredEndpoint {
19
+ readonly provider: string;
20
+ /** The registering session's installation id, never a request field. */
21
+ readonly installationId: string;
22
+ readonly player: string;
23
+ readonly address: NotificationDeviceAddress;
24
+ /** The device permission the latest registration reported. */
25
+ readonly permission: NotificationPermissionState;
26
+ readonly status: 'valid' | 'invalid';
27
+ readonly locale?: string;
28
+ readonly timeZone?: string;
29
+ /** The catalog target whose mapping applies to this device (`android`, `ios`). */
30
+ readonly target?: string;
31
+ /** The session that registered the endpoint; its end unbinds it. */
32
+ readonly sessionId: string;
33
+ /** When that session ends on its own; the endpoint counts as unbound from then. */
34
+ readonly sessionExpiresAt?: string;
35
+ readonly lastRegisteredAt: string;
36
+ readonly invalidatedAt?: string;
37
+ }
38
+ /** What a registration writes; the store decides `status`. */
39
+ export type NotificationEndpointWrite = Omit<NotificationStoredEndpoint, 'status' | 'invalidatedAt'>;
40
+ /** A platform-user registration: only the locale and zone; the recipient comes from the identity. */
41
+ export interface NotificationPlatformRegistration {
42
+ readonly player: string;
43
+ readonly provider: string;
44
+ readonly locale?: string;
45
+ readonly timeZone?: string;
46
+ readonly registeredAt: string;
47
+ }
48
+ export type NotificationNoticeChannel = 'in-game' | 'push' | 'other';
49
+ /** A notice given to a player: which notice (its name), how, and when. */
50
+ export interface NotificationNoticeGiven extends NotificationNoticeIdentity {
51
+ readonly player: string;
52
+ readonly channel: NotificationNoticeChannel;
53
+ readonly at: string;
54
+ }
55
+ /** One delivered send, which the gate's frequency caps and provider limits count. */
56
+ export interface NotificationHistoryEntry {
57
+ readonly sendKey: string;
58
+ readonly provider: string;
59
+ readonly player: string;
60
+ readonly topic: string;
61
+ readonly at: string;
62
+ }
63
+ /** The request a send key is bound to (`notificationSendFingerprint`). */
64
+ export interface NotificationStoredSendRequest {
65
+ readonly idempotencyKey: string;
66
+ readonly player: string;
67
+ readonly provider?: string;
68
+ readonly topic: string;
69
+ readonly templateData?: NotificationTemplateData;
70
+ readonly data?: Readonly<Record<string, string>>;
71
+ readonly locale?: string;
72
+ readonly link?: string;
73
+ }
74
+ /** Why the send exists: a direct send, or a scheduler item (a server reminder or a consent notice). */
75
+ export type NotificationSendOrigin = {
76
+ readonly kind: 'send';
77
+ } | {
78
+ readonly kind: 'reminder';
79
+ readonly key: string;
80
+ } | {
81
+ readonly kind: 'notice';
82
+ readonly notice: NotificationNoticeIdentity;
83
+ readonly variant: NotificationNoticeVariant;
84
+ };
85
+ /** Rate-limit backoff of one recipient, from its first `rate-limited` answer. */
86
+ export interface NotificationRateLimitState {
87
+ readonly since: string;
88
+ readonly delaySeconds: number;
89
+ readonly attempts: number;
90
+ }
91
+ export type NotificationSendRowState = {
92
+ readonly status: 'delivered';
93
+ } | {
94
+ readonly status: 'deferred';
95
+ readonly reason: NotificationDeferralReason;
96
+ readonly until: string;
97
+ } | {
98
+ /**
99
+ * Claimed or waiting to be claimed. `claim` names the holder; a row
100
+ * without one is queued and was never sent. `attempted` turns true when
101
+ * a holder claimed it to call the provider, so an expired lease means
102
+ * the provider may have sent it.
103
+ */
104
+ readonly status: 'in-flight';
105
+ readonly leaseUntil: string;
106
+ readonly claim?: string;
107
+ readonly attempted: boolean;
108
+ /** When the latest holder claimed it to call the provider. */
109
+ readonly attemptedAt?: string;
110
+ };
111
+ /** One recipient's ledger delivery of a send. */
112
+ export interface NotificationSendRow {
113
+ /** The ledger key: the send key, or `<send key>:<encodeURIComponent(installationId)>`. */
114
+ readonly key: string;
115
+ readonly provider: string;
116
+ /** The endpoint's installation, for device addressing. */
117
+ readonly installationId?: string;
118
+ readonly state: NotificationSendRowState;
119
+ readonly rateLimit?: NotificationRateLimitState;
120
+ /**
121
+ * When a claim that may have reached the provider first took the row. A
122
+ * deferral or a rate-limit answer, which prove nothing was sent, keep the
123
+ * earlier value, so ambiguous replays stop 24 hours after a real attempt.
124
+ */
125
+ readonly firstAttemptedAt?: string;
126
+ /** Ambiguous answers so far; each doubles the lease before the next replay. */
127
+ readonly ambiguousAttempts?: number;
128
+ }
129
+ /** A send key's stored state. */
130
+ export interface NotificationStoredSend {
131
+ readonly key: string;
132
+ readonly fingerprint: string;
133
+ readonly request: NotificationStoredSendRequest;
134
+ readonly origin: NotificationSendOrigin;
135
+ /**
136
+ * Whether a scheduler item (a reminder or a notice) or a due replay owns
137
+ * the send. Every in-flight row whose lease ended is due either way, so no
138
+ * rule reads it; it tells an operator who made the send.
139
+ */
140
+ readonly scheduled: boolean;
141
+ readonly rows: readonly NotificationSendRow[];
142
+ /** Held while the first evaluation runs, before any row exists. */
143
+ readonly claim?: {
144
+ readonly token: string;
145
+ readonly leaseUntil: string;
146
+ };
147
+ readonly changedAt: string;
148
+ }
149
+ /** A stored send with the version a conditional write must name. */
150
+ export interface NotificationVersionedSend {
151
+ readonly send: NotificationStoredSend;
152
+ readonly version: string;
153
+ }
154
+ export interface NotificationPurgeCutoffs {
155
+ /** Sends whose last change is older than this are forgotten (key retention). */
156
+ readonly sendsChangedBefore: string;
157
+ readonly historyBefore: string;
158
+ /** Endpoints invalidated before this are deleted. */
159
+ readonly invalidEndpointsBefore: string;
160
+ /** Endpoints last registered before this are deleted. */
161
+ readonly staleEndpointsBefore: string;
162
+ /** Endpoints whose session ended before this are deleted. */
163
+ readonly sessionEndpointsBefore: string;
164
+ }
165
+ export interface NotificationPurgeCounts {
166
+ readonly sends: number;
167
+ readonly history: number;
168
+ readonly endpoints: number;
169
+ }
170
+ /**
171
+ * A server reminder a player set (RFC "Reminders"): one per player and key.
172
+ * Once the scheduler turned it into its `reminder:` send it is `firedAt`,
173
+ * no longer pending, and kept only so a later set or cancel can stop that
174
+ * send while it still waits.
175
+ */
176
+ export interface NotificationStoredReminder {
177
+ readonly kind: 'reminder';
178
+ readonly key: string;
179
+ readonly topic: string;
180
+ /** Canonical RFC 3339. */
181
+ readonly fireAt: string;
182
+ readonly templateData?: NotificationTemplateData;
183
+ readonly link?: string;
184
+ readonly firedAt?: string;
185
+ /** A fired reminder's send fingerprint (its content), which names the send it made. */
186
+ readonly fingerprint?: string;
187
+ }
188
+ /** When the scheduler next looks at a player's consent notices, with the backoff of refused pushes. */
189
+ export interface NotificationNoticeCheck {
190
+ readonly kind: 'notices';
191
+ /** Notice pushes refused in a row; each refusal doubles the wait before the next try. */
192
+ readonly attempts: number;
193
+ /** Notice send keys whose push could not render; each fails once and is not tried again. */
194
+ readonly failed?: readonly string[];
195
+ /**
196
+ * Notice send keys the last check pushed: one that is gone at the next
197
+ * check while its notice is still owed was refused when it was sent.
198
+ */
199
+ readonly pushed?: readonly string[];
200
+ }
201
+ export type NotificationScheduleItem = NotificationStoredReminder | NotificationNoticeCheck;
202
+ /**
203
+ * One scheduler item of a player, named by `id` (`reminder:<key>` for a
204
+ * reminder, `notices` for the notice check). The store keeps the item as
205
+ * given and indexes `dueAt`; the service decides what it means.
206
+ */
207
+ export interface NotificationScheduleEntry {
208
+ readonly player: string;
209
+ readonly id: string;
210
+ /** When the scheduler takes the entry next; an entry without one is never listed as due. */
211
+ readonly dueAt?: string;
212
+ readonly item: NotificationScheduleItem;
213
+ }
214
+ /** A schedule entry with the version a conditional write must name. */
215
+ export interface NotificationVersionedScheduleEntry {
216
+ readonly entry: NotificationScheduleEntry;
217
+ readonly version: string;
218
+ }
219
+ export interface NotificationStore {
220
+ /**
221
+ * Appends a consent record for `player`, dated `at` or, when the player's
222
+ * latest record is not earlier, 1 ms after it, so a record's kind and time
223
+ * name it. Answers the stored record. Must be atomic per player.
224
+ */
225
+ appendConsent(input: {
226
+ readonly player: string;
227
+ readonly record: NotificationConsentRecordInput;
228
+ readonly at: string;
229
+ }): Promise<NotificationConsentRecord>;
230
+ /** The player's consent records, oldest first. */
231
+ listConsent(player: string): Promise<readonly NotificationConsentRecord[]>;
232
+ /**
233
+ * Deletes the player's consent records dated at these instants (each of a
234
+ * player's records has its own millisecond, so the instant names it), and
235
+ * the notices given that report on them, in one atomic step. Answers how
236
+ * many records it deleted. Callers never prune a player's latest record:
237
+ * `appendConsent` dates a new record after the latest one that is left, so
238
+ * an instant names one record for good only while the latest one stays.
239
+ */
240
+ pruneConsent(input: {
241
+ readonly player: string;
242
+ readonly at: readonly string[];
243
+ }): Promise<number>;
244
+ /**
245
+ * Binds a device endpoint, atomically: another installation's endpoint
246
+ * with the same provider and address value is removed (an address is
247
+ * unique per provider), and the endpoint stays `invalid` when its
248
+ * installation re-registers the same dead address; any other write is
249
+ * `valid`.
250
+ */
251
+ putEndpoint(endpoint: NotificationEndpointWrite): Promise<NotificationStoredEndpoint>;
252
+ listEndpoints(input: {
253
+ readonly player: string;
254
+ readonly provider: string;
255
+ }): Promise<readonly NotificationStoredEndpoint[]>;
256
+ getEndpoint(input: {
257
+ readonly provider: string;
258
+ readonly installationId: string;
259
+ }): Promise<NotificationStoredEndpoint | undefined>;
260
+ /** Marks the endpoint invalid while it still holds `value`. */
261
+ invalidateEndpoint(input: {
262
+ readonly provider: string;
263
+ readonly installationId: string;
264
+ readonly value: string;
265
+ readonly at: string;
266
+ }): Promise<void>;
267
+ /** Removes the installation's endpoints (of one provider, or all) that `player` holds. */
268
+ removeEndpoints(input: {
269
+ readonly installationId: string;
270
+ readonly player: string;
271
+ readonly provider?: string;
272
+ }): Promise<void>;
273
+ /** Removes every endpoint `sessionId` registered; answers how many. */
274
+ removeSessionEndpoints(input: {
275
+ readonly sessionId: string;
276
+ }): Promise<number>;
277
+ /**
278
+ * Moves the end of every endpoint `sessionId` registered to `expiresAt`
279
+ * where the stored end is earlier, or clears it when the session no longer
280
+ * ends on its own (`expiresAt` absent). An endpoint without an end keeps
281
+ * none, and no end moves earlier. Answers how many changed.
282
+ */
283
+ extendSessionEndpoints(input: {
284
+ readonly sessionId: string;
285
+ readonly expiresAt?: string;
286
+ }): Promise<number>;
287
+ putPlatformRegistration(registration: NotificationPlatformRegistration): Promise<void>;
288
+ getPlatformRegistration(input: {
289
+ readonly player: string;
290
+ readonly provider: string;
291
+ }): Promise<NotificationPlatformRegistration | undefined>;
292
+ /** Records a notice given; a notice already recorded for the player stays as it was. */
293
+ addNoticeGiven(notice: NotificationNoticeGiven): Promise<void>;
294
+ listNoticesGiven(player: string): Promise<readonly NotificationNoticeGiven[]>;
295
+ getSend(key: string): Promise<NotificationVersionedSend | undefined>;
296
+ /** Stores a new send; answers its version, or `undefined` when the key exists. */
297
+ createSend(send: NotificationStoredSend): Promise<string | undefined>;
298
+ /**
299
+ * Replaces (or, with `send: undefined`, deletes) the send while it still
300
+ * has `version`; answers whether it did. Every write gets a new version.
301
+ */
302
+ updateSend(input: {
303
+ readonly key: string;
304
+ readonly version: string;
305
+ readonly send: NotificationStoredSend | undefined;
306
+ }): Promise<boolean>;
307
+ /** The player's sends with at least one `in-flight` row. */
308
+ listInFlightSends(player: string): Promise<readonly NotificationStoredSend[]>;
309
+ /** Up to `limit` send keys with a row due at `at` (`notificationSendDueAt`), earliest first. */
310
+ listDueSends(input: {
311
+ readonly at: string;
312
+ readonly limit: number;
313
+ }): Promise<readonly string[]>;
314
+ /** Records a delivered send for one provider; a second record of the same pair is ignored. */
315
+ addHistory(entry: NotificationHistoryEntry): Promise<void>;
316
+ /** The player's deliveries at or after `since`, oldest first. */
317
+ listHistory(input: {
318
+ readonly player: string;
319
+ readonly since: string;
320
+ }): Promise<readonly NotificationHistoryEntry[]>;
321
+ /**
322
+ * How many deliveries every player received through `provider` at or
323
+ * after `since` and at or before `until`, for the scheduler's pacing. The
324
+ * upper bound leaves out deliveries dated after the run's time (the clock
325
+ * went back). It counts delivered sends only, so pacing is approximate
326
+ * while other sends are in flight.
327
+ */
328
+ countHistory(input: {
329
+ readonly provider: string;
330
+ readonly since: string;
331
+ readonly until: string;
332
+ }): Promise<number>;
333
+ getScheduleEntry(input: {
334
+ readonly player: string;
335
+ readonly id: string;
336
+ }): Promise<NotificationVersionedScheduleEntry | undefined>;
337
+ /**
338
+ * Stores a new entry; answers its version, or `undefined` when the player
339
+ * already has one with its id. A version is never reused, also after the
340
+ * entry is deleted and created again. The store deletes no entry on its
341
+ * own: the service removes a reminder or a notice check it is done with.
342
+ */
343
+ createScheduleEntry(entry: NotificationScheduleEntry): Promise<string | undefined>;
344
+ /**
345
+ * Replaces (or, with `entry: undefined`, deletes) the entry while it still
346
+ * has `version`; answers whether it did. Every write gets a new version.
347
+ * The entry is stored under `player` and `id`, whatever it names.
348
+ */
349
+ updateScheduleEntry(input: {
350
+ readonly player: string;
351
+ readonly id: string;
352
+ readonly version: string;
353
+ readonly entry: NotificationScheduleEntry | undefined;
354
+ }): Promise<boolean>;
355
+ /**
356
+ * How many of the player's entries are pending server reminders: an id
357
+ * that starts with `reminder:` and an item of kind `reminder` without
358
+ * `firedAt`. The service caps a player's pending reminders with it.
359
+ */
360
+ countPendingReminders(player: string): Promise<number>;
361
+ /**
362
+ * Up to `limit` entries whose `dueAt` is at or before `at`: earliest
363
+ * first, then by player and id.
364
+ */
365
+ listDueScheduleEntries(input: {
366
+ readonly at: string;
367
+ readonly limit: number;
368
+ }): Promise<readonly {
369
+ readonly player: string;
370
+ readonly id: string;
371
+ }[]>;
372
+ purge(cutoffs: NotificationPurgeCutoffs): Promise<NotificationPurgeCounts>;
373
+ }
374
+ type StoredOutcome = Extract<NotificationDeliveryOutcome, {
375
+ readonly status: 'delivered' | 'deferred' | 'in-flight';
376
+ }>;
377
+ export declare function notificationRowOutcome(row: NotificationSendRow): StoredOutcome;
378
+ /** The highest-ranked outcome, the earliest among deferred ones, the first among equals. */
379
+ export declare function bestNotificationOutcome<Outcome extends NotificationDeliveryOutcome>(outcomes: readonly Outcome[]): Outcome | undefined;
380
+ /** A send's stored outcome: its best row, or `in-flight` while its first evaluation holds the claim. */
381
+ export declare function notificationSendOutcome(send: NotificationStoredSend): StoredOutcome | undefined;
382
+ /** How long an ambiguous delivery is replayed before it is left in flight. */
383
+ export declare const notificationAmbiguousReplayHours = 24;
384
+ /**
385
+ * Whether an attempted row is past its replays: its lease would end a day
386
+ * or more after its first attempt.
387
+ */
388
+ export declare function isNotificationRowAbandoned(row: NotificationSendRow, leaseUntil: number): boolean;
389
+ /**
390
+ * Whether the scheduler may take a row at `at`: a deferred row once `until`
391
+ * passed, and an in-flight row once its lease expired. That covers a queued
392
+ * delivery whose message was lost, a claim whose holder failed before the
393
+ * provider call, and an ambiguous answer, for a device endpoint, a scheduled
394
+ * send, or a direct platform send whose caller never retries. Ambiguous
395
+ * replays stop 24 hours after the first attempt.
396
+ */
397
+ export declare function isNotificationRowDue(row: NotificationSendRow, at: number): boolean;
398
+ /** When the scheduler may first take one of the send's rows, or `undefined` when none waits for it. */
399
+ export declare function notificationSendDueAt(send: NotificationStoredSend): string | undefined;
400
+ export declare function formatNotificationInstant(millis: number): string;
401
+ /** The in-memory store's rows, for tests and conformance adapters. */
402
+ export interface InMemoryNotificationStoreState {
403
+ readonly consent: readonly (NotificationConsentRecord & {
404
+ readonly player: string;
405
+ })[];
406
+ readonly endpoints: readonly NotificationStoredEndpoint[];
407
+ readonly platformRegistrations: readonly NotificationPlatformRegistration[];
408
+ readonly noticesGiven: readonly NotificationNoticeGiven[];
409
+ readonly sends: readonly NotificationStoredSend[];
410
+ readonly history: readonly NotificationHistoryEntry[];
411
+ readonly schedule: readonly NotificationScheduleEntry[];
412
+ }
413
+ export interface InMemoryNotificationStore extends NotificationStore {
414
+ /** A deep copy of every row, read synchronously. */
415
+ inspect(): InMemoryNotificationStoreState;
416
+ }
417
+ /**
418
+ * A process-local store for tests, conformance runs, and local development.
419
+ * Every method finishes its change before its first await, so concurrent
420
+ * callers see each change whole. Production deployments use a durable store.
421
+ */
422
+ export declare function createInMemoryNotificationStore(): InMemoryNotificationStore;
423
+ export {};