@mpgd/game-services 0.24.0 → 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,795 @@
1
+ /**
2
+ * The behavior every `NotificationStore` must have, run against a fresh
3
+ * store: the atomic steps the service relies on (consent dating, endpoint
4
+ * address uniqueness and dead addresses, versioned compare-and-set on sends
5
+ * and schedule entries, idempotent notices and history, consent pruning,
6
+ * provider-wide history counts) and the purge cutoffs. A durable store
7
+ * (the Worker's D1 store, a Redis store) runs it in its own tests. Throws on
8
+ * the first difference.
9
+ */
10
+ export async function runNotificationStoreConformance(createStore) {
11
+ const store = await createStore();
12
+ const at = (offsetMs) => new Date(Date.parse('2026-10-19T03:00:00Z') + offsetMs)
13
+ .toISOString()
14
+ .replace('.000Z', 'Z');
15
+ // Consent: dated at least 1 ms after the player's previous record, per player.
16
+ const first = await store.appendConsent({
17
+ player: 'p-1',
18
+ record: { kind: 'advertising', state: 'granted', source: 'player', textVersion: 'v1' },
19
+ at: at(0),
20
+ });
21
+ const second = await store.appendConsent({
22
+ player: 'p-1',
23
+ record: { kind: 'night', state: 'granted', source: 'player', textVersion: 'v1' },
24
+ at: at(0),
25
+ });
26
+ const earlier = await store.appendConsent({
27
+ player: 'p-1',
28
+ record: { kind: 'topic-preference', topic: 'energy-full', state: 'granted', source: 'player' },
29
+ at: at(-60_000),
30
+ });
31
+ const other = await store.appendConsent({
32
+ player: 'p-2',
33
+ record: {
34
+ kind: 'host-agreement',
35
+ topic: 'energy-full',
36
+ state: 'denied',
37
+ source: 'provider',
38
+ evidence: { schema: 'fixture.host-agreement.v1', payload: { code: 'x' } },
39
+ },
40
+ at: at(0),
41
+ });
42
+ equal(first.at, at(0), 'consent: the first record keeps the backend time');
43
+ equal(second.at, at(1), 'consent: a second record in the same millisecond is 1 ms later');
44
+ equal(earlier.at, at(2), 'consent: a record dated before the latest moves after it');
45
+ equal(other.at, at(0), 'consent: another player has its own sequence');
46
+ deepEqual((await store.listConsent('p-1')).map((record) => [record.kind, record.at]), [
47
+ ['advertising', at(0)],
48
+ ['night', at(1)],
49
+ ['topic-preference', at(2)],
50
+ ], 'consent: listed oldest first');
51
+ deepEqual(await store.listConsent('p-2'), [other], 'consent: evidence round-trips');
52
+ // Endpoints.
53
+ const endpoint = (installationId, value, extra = {}) => ({
54
+ provider: 'fcm',
55
+ installationId,
56
+ player: 'p-1',
57
+ address: { kind: 'fcm-token', value },
58
+ permission: 'granted',
59
+ sessionId: `session-${installationId}`,
60
+ lastRegisteredAt: at(0),
61
+ ...extra,
62
+ });
63
+ const stored = await store.putEndpoint(endpoint('i-1', 'tok-1', {
64
+ locale: 'ko-KR',
65
+ timeZone: 'Asia/Seoul',
66
+ target: 'android',
67
+ sessionExpiresAt: at(86_400_000),
68
+ address: { kind: 'fcm-token', value: 'tok-1', environment: 'production' },
69
+ }));
70
+ equal(stored.status, 'valid', 'endpoints: a new endpoint is valid');
71
+ deepEqual(await store.getEndpoint({ provider: 'fcm', installationId: 'i-1' }), stored, 'endpoints: read back as written');
72
+ // The same address on another installation moves there.
73
+ await store.putEndpoint(endpoint('i-2', 'tok-1'));
74
+ equal(await store.getEndpoint({ provider: 'fcm', installationId: 'i-1' }), undefined, 'endpoints: an address is unique per provider');
75
+ await store.invalidateEndpoint({
76
+ provider: 'fcm',
77
+ installationId: 'i-2',
78
+ value: 'tok-other',
79
+ at: at(5),
80
+ });
81
+ equal((await store.getEndpoint({ provider: 'fcm', installationId: 'i-2' }))?.status, 'valid', 'endpoints: invalidation names the address');
82
+ await store.invalidateEndpoint({
83
+ provider: 'fcm',
84
+ installationId: 'i-2',
85
+ value: 'tok-1',
86
+ at: at(5),
87
+ });
88
+ const dead = await store.getEndpoint({ provider: 'fcm', installationId: 'i-2' });
89
+ equal(dead?.status, 'invalid', 'endpoints: a dead address is invalid');
90
+ equal(dead?.invalidatedAt, at(5), 'endpoints: the invalidation time is kept');
91
+ equal((await store.putEndpoint(endpoint('i-2', 'tok-1', { lastRegisteredAt: at(10) }))).status, 'invalid', 'endpoints: a dead address stays dead');
92
+ equal((await store.putEndpoint(endpoint('i-2', 'tok-2'))).status, 'valid', 'endpoints: a new address revives the endpoint');
93
+ await store.putEndpoint(endpoint('i-3', 'tok-3', { player: 'p-2', sessionId: 'shared' }));
94
+ await store.putEndpoint({
95
+ ...endpoint('i-4', 'tok-4', { sessionId: 'shared' }),
96
+ provider: 'other',
97
+ });
98
+ deepEqual((await store.listEndpoints({ player: 'p-1', provider: 'fcm' })).map((row) => row.installationId), ['i-2'], 'endpoints: listed per player and provider');
99
+ await store.removeEndpoints({ installationId: 'i-3', player: 'p-1' });
100
+ equal((await store.getEndpoint({ provider: 'fcm', installationId: 'i-3' }))?.player, 'p-2', 'endpoints: an unbind names the player');
101
+ // A session's later end moves its endpoints' ends; an earlier one or another session's does not.
102
+ await store.putEndpoint(endpoint('i-5', 'tok-5', { sessionId: 'extended', sessionExpiresAt: at(60_000) }));
103
+ await store.putEndpoint(endpoint('i-6', 'tok-6', { sessionId: 'extended' }));
104
+ await store.putEndpoint(endpoint('i-7', 'tok-7', { sessionId: 'other', sessionExpiresAt: at(60_000) }));
105
+ equal(await store.extendSessionEndpoints({ sessionId: 'extended', expiresAt: at(30_000) }), 0, 'endpoints: a session end never moves earlier');
106
+ equal(await store.extendSessionEndpoints({ sessionId: 'extended', expiresAt: at(60_000) }), 0, 'endpoints: the same end changes nothing');
107
+ equal(await store.extendSessionEndpoints({ sessionId: 'extended', expiresAt: at(120_000) }), 1, 'endpoints: a later session end moves only the endpoints that have one');
108
+ equal(Date.parse((await store.getEndpoint({ provider: 'fcm', installationId: 'i-5' }))?.sessionExpiresAt ?? ''), Date.parse(at(120_000)), 'endpoints: the moved end reads back');
109
+ equal((await store.getEndpoint({ provider: 'fcm', installationId: 'i-6' }))?.sessionExpiresAt, undefined, 'endpoints: an endpoint without an end keeps none');
110
+ equal(Date.parse((await store.getEndpoint({ provider: 'fcm', installationId: 'i-7' }))?.sessionExpiresAt ?? ''), Date.parse(at(60_000)), 'endpoints: only the named session moves');
111
+ equal(await store.extendSessionEndpoints({ sessionId: 'extended' }), 1, 'endpoints: a session that no longer ends clears the end');
112
+ equal((await store.getEndpoint({ provider: 'fcm', installationId: 'i-5' }))?.sessionExpiresAt, undefined, 'endpoints: the cleared end reads back');
113
+ await store.removeSessionEndpoints({ sessionId: 'extended' });
114
+ await store.removeSessionEndpoints({ sessionId: 'other' });
115
+ equal(await store.removeSessionEndpoints({ sessionId: 'shared' }), 2, 'endpoints: the session end unbinds what it registered');
116
+ await store.removeEndpoints({ installationId: 'i-2', player: 'p-1', provider: 'fcm' });
117
+ deepEqual(await store.listEndpoints({ player: 'p-1', provider: 'fcm' }), [], 'endpoints: unbound');
118
+ // Platform registrations.
119
+ await store.putPlatformRegistration({
120
+ player: 'p-1',
121
+ provider: 'ait',
122
+ locale: 'ko',
123
+ registeredAt: at(0),
124
+ });
125
+ await store.putPlatformRegistration({
126
+ player: 'p-1',
127
+ provider: 'ait',
128
+ timeZone: 'Asia/Seoul',
129
+ registeredAt: at(1),
130
+ });
131
+ deepEqual(await store.getPlatformRegistration({ player: 'p-1', provider: 'ait' }), { player: 'p-1', provider: 'ait', timeZone: 'Asia/Seoul', registeredAt: at(1) }, 'platform registrations: the latest replaces the earlier');
132
+ equal(await store.getPlatformRegistration({ player: 'p-2', provider: 'ait' }), undefined, 'platform registrations: per player');
133
+ // Notices given: idempotent by name, compared as instants.
134
+ const notice = {
135
+ kind: 'consent-result',
136
+ record: { kind: 'advertising', at: at(0) },
137
+ dueBy: '2026-11-02',
138
+ player: 'p-1',
139
+ channel: 'in-game',
140
+ at: at(100),
141
+ };
142
+ await store.addNoticeGiven(notice);
143
+ await store.addNoticeGiven({ ...notice, channel: 'push', at: at(200) });
144
+ deepEqual(await store.listNoticesGiven('p-1'), [notice], 'notices: recorded once');
145
+ // Sends: create once, compare-and-set on the version, delete.
146
+ const send = {
147
+ key: 'k-1',
148
+ fingerprint: 'f',
149
+ request: { idempotencyKey: 'k-1', player: 'p-1', topic: 'energy-full' },
150
+ origin: { kind: 'send' },
151
+ scheduled: false,
152
+ rows: [
153
+ {
154
+ key: 'k-1:i-1',
155
+ provider: 'fcm',
156
+ installationId: 'i-1',
157
+ state: { status: 'deferred', reason: 'quiet-hours', until: at(60_000) },
158
+ },
159
+ ],
160
+ changedAt: at(0),
161
+ };
162
+ const version = await store.createSend(send);
163
+ ok(typeof version === 'string', 'sends: created');
164
+ equal(await store.createSend(send), undefined, 'sends: a key is created once');
165
+ const held = await store.getSend('k-1');
166
+ deepEqual(held?.send, send, 'sends: read back as written');
167
+ equal(held?.version, version, 'sends: with its version');
168
+ deepEqual(await store.listDueSends({ at: at(59_999), limit: 10 }), [], 'sends: not due before until');
169
+ deepEqual(await store.listDueSends({ at: at(60_000), limit: 10 }), ['k-1'], 'sends: due at until');
170
+ const inFlight = {
171
+ ...send,
172
+ rows: [
173
+ {
174
+ key: 'k-1:i-1',
175
+ provider: 'fcm',
176
+ installationId: 'i-1',
177
+ state: { status: 'in-flight', leaseUntil: at(300_000), claim: 'c', attempted: true },
178
+ },
179
+ ],
180
+ };
181
+ ok(await store.updateSend({ key: 'k-1', version: version ?? '', send: inFlight }), 'sends: a write with the version succeeds');
182
+ ok(!await store.updateSend({ key: 'k-1', version: version ?? '', send }), 'sends: a stale version is refused');
183
+ deepEqual((await store.listInFlightSends('p-1')).map((row) => row.key), ['k-1'], 'sends: in-flight by player');
184
+ deepEqual(await store.listInFlightSends('p-2'), [], 'sends: in-flight is per player');
185
+ deepEqual(await store.listDueSends({ at: at(300_000), limit: 10 }), ['k-1'], 'sends: a device lease is due when it ends');
186
+ const current = await store.getSend('k-1');
187
+ ok(current !== undefined && current.version !== version, 'sends: every write changes the version');
188
+ ok(await store.updateSend({ key: 'k-1', version: current?.version ?? '', send: undefined }), 'sends: deleted with the version');
189
+ equal(await store.getSend('k-1'), undefined, 'sends: gone');
190
+ ok(!await store.updateSend({ key: 'k-1', version: current?.version ?? '', send }), 'sends: a deleted key is not written back');
191
+ for (const [key, until] of [
192
+ ['k-3', 30],
193
+ ['k-2', 10],
194
+ ['k-4', 20],
195
+ ]) {
196
+ await store.createSend({
197
+ ...send,
198
+ key,
199
+ request: { ...send.request, idempotencyKey: key },
200
+ rows: [
201
+ { ...send.rows[0], key: `${key}:i-1`, state: { status: 'deferred', reason: 'quiet-hours', until: at(until) } },
202
+ ],
203
+ });
204
+ }
205
+ deepEqual(await store.listDueSends({ at: at(100), limit: 2 }), ['k-2', 'k-4'], 'sends: due earliest first, limited');
206
+ // History: one entry per send and provider, read from a time on.
207
+ await store.addHistory({
208
+ sendKey: 'h-1',
209
+ provider: 'fcm',
210
+ player: 'p-1',
211
+ topic: 't',
212
+ at: at(10),
213
+ });
214
+ await store.addHistory({
215
+ sendKey: 'h-1',
216
+ provider: 'fcm',
217
+ player: 'p-1',
218
+ topic: 't',
219
+ at: at(20),
220
+ });
221
+ await store.addHistory({ sendKey: 'h-2', provider: 'fcm', player: 'p-1', topic: 't', at: at(5) });
222
+ await store.addHistory({
223
+ sendKey: 'h-3',
224
+ provider: 'fcm',
225
+ player: 'p-2',
226
+ topic: 't',
227
+ at: at(30),
228
+ });
229
+ deepEqual((await store.listHistory({ player: 'p-1', since: at(0) })).map((entry) => [
230
+ entry.sendKey,
231
+ entry.at,
232
+ ]), [
233
+ ['h-2', at(5)],
234
+ ['h-1', at(10)],
235
+ ], 'history: once per send and provider, oldest first');
236
+ deepEqual((await store.listHistory({ player: 'p-1', since: at(10) })).map((entry) => entry.sendKey), ['h-1'], 'history: since is inclusive');
237
+ // Purge.
238
+ await store.putEndpoint(endpoint('p-invalid', 'tok-p1'));
239
+ await store.invalidateEndpoint({
240
+ provider: 'fcm',
241
+ installationId: 'p-invalid',
242
+ value: 'tok-p1',
243
+ at: at(0),
244
+ });
245
+ await store.putEndpoint(endpoint('p-stale', 'tok-p2', { lastRegisteredAt: at(-1) }));
246
+ await store.putEndpoint(endpoint('p-session', 'tok-p3', { sessionExpiresAt: at(-1) }));
247
+ await store.putEndpoint(endpoint('p-kept', 'tok-p4', { lastRegisteredAt: at(1), sessionExpiresAt: at(1) }));
248
+ const counts = await store.purge({
249
+ sendsChangedBefore: at(1),
250
+ historyBefore: at(10),
251
+ invalidEndpointsBefore: at(1),
252
+ staleEndpointsBefore: at(0),
253
+ sessionEndpointsBefore: at(0),
254
+ });
255
+ deepEqual(counts, { sends: 3, history: 1, endpoints: 3 }, 'purge: counts what it deleted');
256
+ deepEqual((await store.listEndpoints({ player: 'p-1', provider: 'fcm' })).map((row) => row.installationId), ['p-kept'], 'purge: keeps endpoints inside every cutoff');
257
+ deepEqual((await store.listHistory({ player: 'p-1', since: at(0) })).map((entry) => entry.sendKey), ['h-1'], 'purge: history from the cutoff on is kept');
258
+ await runConcurrencyAndEdgeClauses(store, at, endpoint, send);
259
+ await runSchedulerClauses(store, at);
260
+ }
261
+ /**
262
+ * The steps S2's scheduler adds: schedule entries (create once,
263
+ * compare-and-set, the due index), provider-wide history counts for
264
+ * pacing, and consent pruning with the notices that name pruned records.
265
+ */
266
+ async function runSchedulerClauses(store, at) {
267
+ const base = 40 * 86_400_000;
268
+ const t = (offsetMs) => at(base + offsetMs);
269
+ // Consent pruning: by instant, per player, with the notices given that report on a pruned record.
270
+ const records = [];
271
+ for (const record of [
272
+ { kind: 'advertising', state: 'granted', source: 'player', textVersion: 'v1' },
273
+ { kind: 'advertising', state: 'withdrawn', source: 'player', textVersion: 'v1' },
274
+ { kind: 'topic-preference', topic: 'energy-full', state: 'granted', source: 'player' },
275
+ { kind: 'advertising', state: 'granted', source: 'player', textVersion: 'v2' },
276
+ ]) {
277
+ records.push(await store.appendConsent({ player: 'p-prune', record, at: t(0) }));
278
+ }
279
+ const other = await store.appendConsent({
280
+ player: 'p-prune-other',
281
+ record: { kind: 'advertising', state: 'granted', source: 'player', textVersion: 'v1' },
282
+ at: t(0),
283
+ });
284
+ const [granted, withdrawn, preference, regranted] = records;
285
+ const given = (recordAt, dueBy, player = 'p-prune') => ({
286
+ kind: 'consent-result',
287
+ record: { kind: 'advertising', at: recordAt },
288
+ dueBy,
289
+ player,
290
+ channel: 'push',
291
+ at: t(10),
292
+ });
293
+ await store.addNoticeGiven(given(granted?.at ?? '', '2026-12-12'));
294
+ await store.addNoticeGiven(given(regranted?.at ?? '', '2026-12-13'));
295
+ await store.addNoticeGiven(given(other.at, '2026-12-12', 'p-prune-other'));
296
+ equal(await store.pruneConsent({
297
+ player: 'p-prune',
298
+ // The same instant in another spelling names the same record.
299
+ at: [
300
+ granted?.at ?? '',
301
+ new Date(Date.parse(withdrawn?.at ?? '')).toISOString().replace('Z', '+00:00'),
302
+ // An instant with no record of this player.
303
+ t(-5),
304
+ ],
305
+ }), 2, 'prune: deletes the records at those instants');
306
+ deepEqual((await store.listConsent('p-prune')).map((record) => record.at), [preference?.at, regranted?.at], 'prune: the other records stay, oldest first');
307
+ deepEqual((await store.listNoticesGiven('p-prune')).map((notice) => notice.record.at), [regranted?.at], 'prune: a notice given about a pruned record goes with it');
308
+ equal((await store.listConsent('p-prune-other')).length, 1, 'prune: per player');
309
+ equal((await store.listNoticesGiven('p-prune-other')).length, 1, 'prune: another player keeps its notices');
310
+ equal(await store.pruneConsent({ player: 'p-prune', at: [] }), 0, 'prune: nothing named, nothing deleted');
311
+ const next = await store.appendConsent({
312
+ player: 'p-prune',
313
+ record: { kind: 'night', state: 'granted', source: 'player', textVersion: 'v1' },
314
+ at: t(0),
315
+ });
316
+ equal(Date.parse(next.at), Date.parse(regranted?.at ?? '') + 1, 'prune: a later record is still dated after the latest one');
317
+ // History counts per provider across players, from `since` to `until`, both inclusive.
318
+ for (const [sendKey, provider, player, offset] of [
319
+ ['count-1', 'paced', 'p-a', 100],
320
+ ['count-2', 'paced', 'p-b', 200],
321
+ ['count-3', 'paced', 'p-a', 300],
322
+ ['count-4', 'other-paced', 'p-a', 300],
323
+ ]) {
324
+ await store.addHistory({ sendKey, provider, player, topic: 't', at: t(offset) });
325
+ }
326
+ equal(await store.countHistory({ provider: 'paced', since: t(0), until: t(300) }), 3, 'count: every player');
327
+ equal(await store.countHistory({ provider: 'paced', since: t(200), until: t(300) }), 2, 'count: since is inclusive');
328
+ equal(await store.countHistory({ provider: 'paced', since: t(301), until: t(400) }), 0, 'count: none after');
329
+ equal(await store.countHistory({ provider: 'other-paced', since: t(0), until: t(300) }), 1, 'count: per provider');
330
+ equal(await store.countHistory({ provider: 'paced', since: t(0), until: t(299) }), 2, 'count: nothing after until (a clock that went back)');
331
+ equal(await store.countHistory({ provider: 'paced', since: t(100), until: t(100) }), 1, 'count: until is inclusive');
332
+ // Schedule entries: create once, compare-and-set, the due index.
333
+ const entry = (player, id, dueAt) => ({
334
+ player,
335
+ id,
336
+ ...(dueAt === undefined ? {} : { dueAt }),
337
+ item: {
338
+ kind: 'reminder',
339
+ key: id.slice('reminder:'.length),
340
+ topic: 'energy-full',
341
+ fireAt: dueAt ?? t(0),
342
+ templateData: { count: 3, ready: true, name: 'x' },
343
+ link: 't3_x',
344
+ },
345
+ });
346
+ const first = entry('p-s', 'reminder:energy', t(1_000));
347
+ const version = await store.createScheduleEntry(first);
348
+ ok(typeof version === 'string', 'schedule: created');
349
+ equal(await store.createScheduleEntry(first), undefined, 'schedule: an id is created once per player');
350
+ const held = await store.getScheduleEntry({ player: 'p-s', id: 'reminder:energy' });
351
+ deepEqual(held?.entry, first, 'schedule: read back as written');
352
+ equal(held?.version, version, 'schedule: with its version');
353
+ equal(await store.getScheduleEntry({ player: 'p-s2', id: 'reminder:energy' }), undefined, 'schedule: per player');
354
+ ok(typeof await store.createScheduleEntry(entry('p-s2', 'reminder:energy', t(500))) === 'string', 'schedule: another player has its own ids');
355
+ await store.createScheduleEntry({
356
+ player: 'p-s',
357
+ id: 'notices',
358
+ item: { kind: 'notices', attempts: 2, failed: ['k'] },
359
+ });
360
+ await store.createScheduleEntry(entry('p-s3', 'reminder:later', t(2_000)));
361
+ deepEqual(await store.listDueScheduleEntries({ at: t(999), limit: 10 }), [{ player: 'p-s2', id: 'reminder:energy' }], 'schedule: not due before dueAt');
362
+ deepEqual(await store.listDueScheduleEntries({ at: t(2_000), limit: 10 }), [
363
+ { player: 'p-s2', id: 'reminder:energy' },
364
+ { player: 'p-s', id: 'reminder:energy' },
365
+ { player: 'p-s3', id: 'reminder:later' },
366
+ ], 'schedule: due at dueAt, earliest first; an entry without dueAt is never due');
367
+ deepEqual(await store.listDueScheduleEntries({ at: t(2_000), limit: 1 }), [{ player: 'p-s2', id: 'reminder:energy' }], 'schedule: limited');
368
+ const moved = {
369
+ ...first,
370
+ dueAt: t(3_000),
371
+ item: { ...first.item, fireAt: t(3_000) },
372
+ };
373
+ ok(await store.updateScheduleEntry({ player: 'p-s', id: 'reminder:energy', version: version ?? '', entry: moved }), 'schedule: a write with the version succeeds');
374
+ ok(!await store.updateScheduleEntry({ player: 'p-s', id: 'reminder:energy', version: version ?? '', entry: first }), 'schedule: a stale version is refused');
375
+ const current = await store.getScheduleEntry({ player: 'p-s', id: 'reminder:energy' });
376
+ deepEqual(current?.entry, moved, 'schedule: the write is stored');
377
+ ok(current !== undefined && current.version !== version, 'schedule: every write changes the version');
378
+ deepEqual((await store.listDueScheduleEntries({ at: t(2_999), limit: 10 })).map((due) => due.player), ['p-s2', 'p-s3'], 'schedule: the due index follows the write');
379
+ const notices = await store.getScheduleEntry({ player: 'p-s', id: 'notices' });
380
+ deepEqual(notices?.entry, { player: 'p-s', id: 'notices', item: { kind: 'notices', attempts: 2, failed: ['k'] } }, 'schedule: an entry without a due time round-trips');
381
+ ok(notices !== undefined && await store.updateScheduleEntry({
382
+ player: 'p-s',
383
+ id: 'notices',
384
+ version: notices.version,
385
+ entry: { player: 'p-s', id: 'notices', dueAt: t(0), item: { kind: 'notices', attempts: 0 } },
386
+ }), 'schedule: an entry gains a due time');
387
+ deepEqual((await store.listDueScheduleEntries({ at: t(0), limit: 10 })).map((due) => due.id), ['notices'], 'schedule: and is listed from then');
388
+ ok(await store.updateScheduleEntry({
389
+ player: 'p-s',
390
+ id: 'reminder:energy',
391
+ version: current?.version ?? '',
392
+ entry: undefined,
393
+ }), 'schedule: deleted with the version');
394
+ equal(await store.getScheduleEntry({ player: 'p-s', id: 'reminder:energy' }), undefined, 'schedule: gone');
395
+ ok(!await store.updateScheduleEntry({
396
+ player: 'p-s',
397
+ id: 'reminder:energy',
398
+ version: current?.version ?? '',
399
+ entry: first,
400
+ }), 'schedule: a deleted entry is not written back');
401
+ // Parallel callers.
402
+ const created = await Promise.all(Array.from({ length: 6 }, () => store.createScheduleEntry(entry('p-race', 'reminder:r', t(0)))));
403
+ equal(created.filter((value) => value !== undefined).length, 1, 'concurrency: one of parallel creates wins');
404
+ const raced = await store.getScheduleEntry({ player: 'p-race', id: 'reminder:r' });
405
+ const updated = await Promise.all(Array.from({ length: 6 }, (_, index) => store.updateScheduleEntry({
406
+ player: 'p-race',
407
+ id: 'reminder:r',
408
+ version: raced?.version ?? '',
409
+ entry: entry('p-race', 'reminder:r', t(index + 1)),
410
+ })));
411
+ equal(updated.filter(Boolean).length, 1, 'concurrency: one of parallel writes with one version wins');
412
+ const winner = updated.findIndex(Boolean);
413
+ equal((await store.getScheduleEntry({ player: 'p-race', id: 'reminder:r' }))?.entry.dueAt, t(winner + 1), "concurrency: the stored entry is the winner's");
414
+ // Ties, a due time removed, stale deletes, and versions after a delete.
415
+ for (const [player, id] of [
416
+ ['p-tie-b', 'reminder:a'],
417
+ ['p-tie-a', 'reminder:b'],
418
+ ['p-tie-a', 'reminder:a'],
419
+ ]) {
420
+ await store.createScheduleEntry(entry(player, id, t(5_000)));
421
+ }
422
+ deepEqual((await store.listDueScheduleEntries({ at: t(5_000), limit: 100 })).filter((due) => due.player.startsWith('p-tie')), [
423
+ { player: 'p-tie-a', id: 'reminder:a' },
424
+ { player: 'p-tie-a', id: 'reminder:b' },
425
+ { player: 'p-tie-b', id: 'reminder:a' },
426
+ ], 'schedule: ties by player, then id');
427
+ const tie = await store.getScheduleEntry({ player: 'p-tie-b', id: 'reminder:a' });
428
+ const { dueAt: _due, ...waiting } = tie?.entry ?? first;
429
+ ok(await store.updateScheduleEntry({
430
+ player: 'p-tie-b',
431
+ id: 'reminder:a',
432
+ version: tie?.version ?? '',
433
+ entry: { ...waiting, item: { ...first.item, firedAt: t(5_000) } },
434
+ }), 'schedule: an entry loses its due time');
435
+ ok(!(await store.listDueScheduleEntries({ at: t(10_000), limit: 100 })).some((due) => due.player === 'p-tie-b'), 'schedule: and is no longer listed');
436
+ deepEqual((await store.getScheduleEntry({ player: 'p-tie-b', id: 'reminder:a' }))?.entry, { ...waiting, item: { ...first.item, firedAt: t(5_000) } }, 'schedule: the entry stays without its due time');
437
+ const named = await store.getScheduleEntry({ player: 'p-tie-b', id: 'reminder:a' });
438
+ ok(await store.updateScheduleEntry({
439
+ player: 'p-tie-b',
440
+ id: 'reminder:a',
441
+ version: named?.version ?? '',
442
+ entry: entry('p-elsewhere', 'reminder:elsewhere', undefined),
443
+ }), 'schedule: a write that names another player and id');
444
+ const renamed = await store.getScheduleEntry({ player: 'p-tie-b', id: 'reminder:a' });
445
+ deepEqual([
446
+ renamed?.entry.player,
447
+ renamed?.entry.id,
448
+ await store.getScheduleEntry({ player: 'p-elsewhere', id: 'reminder:elsewhere' }),
449
+ ], ['p-tie-b', 'reminder:a', undefined], 'schedule: is stored under the player and id it was written to');
450
+ const tieA = await store.getScheduleEntry({ player: 'p-tie-a', id: 'reminder:a' });
451
+ ok(!await store.updateScheduleEntry({
452
+ player: 'p-tie-a',
453
+ id: 'reminder:a',
454
+ version: `${tieA?.version ?? ''}-stale`,
455
+ entry: undefined,
456
+ }), 'schedule: a delete with a stale version is refused');
457
+ ok((await store.getScheduleEntry({ player: 'p-tie-a', id: 'reminder:a' })) !== undefined, 'schedule: and the entry stays');
458
+ ok(await store.updateScheduleEntry({
459
+ player: 'p-tie-a',
460
+ id: 'reminder:a',
461
+ version: tieA?.version ?? '',
462
+ entry: undefined,
463
+ }), 'schedule: deleted');
464
+ const again = await store.createScheduleEntry(entry('p-tie-a', 'reminder:a', t(5_000)));
465
+ ok(typeof again === 'string' && again !== tieA?.version, 'schedule: a recreated entry has a new version');
466
+ ok(!await store.updateScheduleEntry({
467
+ player: 'p-tie-a',
468
+ id: 'reminder:a',
469
+ version: tieA?.version ?? '',
470
+ entry: entry('p-tie-a', 'reminder:a', t(6_000)),
471
+ }), "schedule: the deleted entry's version does not write the recreated one");
472
+ const noticeCheck = await store.getScheduleEntry({ player: 'p-s', id: 'notices' });
473
+ deepEqual(noticeCheck?.entry, { player: 'p-s', id: 'notices', dueAt: t(0), item: { kind: 'notices', attempts: 0 } }, 'schedule: a notice check round-trips');
474
+ // Ties compare by code point (SQLite's BINARY order), not by UTF-16 code unit,
475
+ // for the id and for the player.
476
+ await store.createScheduleEntry(entry('p-cp', 'reminder:\u{1F600}', t(7_000)));
477
+ await store.createScheduleEntry(entry('p-cp', 'reminder:\uFFEE', t(7_000)));
478
+ await store.createScheduleEntry(entry('p-cp-\u{1F600}', 'reminder:x', t(7_000)));
479
+ await store.createScheduleEntry(entry('p-cp-\uFFEE', 'reminder:x', t(7_000)));
480
+ deepEqual((await store.listDueScheduleEntries({ at: t(7_000), limit: 100 }))
481
+ .filter((due) => due.player.startsWith('p-cp'))
482
+ .map((due) => [due.player, due.id]), [
483
+ ['p-cp', 'reminder:\uFFEE'],
484
+ ['p-cp', 'reminder:\u{1F600}'],
485
+ ['p-cp-\uFFEE', 'reminder:x'],
486
+ ['p-cp-\u{1F600}', 'reminder:x'],
487
+ ], 'schedule: ties by code point');
488
+ // A time that does not parse is refused before anything is written or compared.
489
+ const bad = { ...entry('p-bad', 'reminder:bad', t(0)), dueAt: 'not-a-time' };
490
+ await rejects(() => store.createScheduleEntry(bad), 'invalid: a due time that does not parse');
491
+ const valid = await store.createScheduleEntry(entry('p-bad', 'reminder:bad', t(0)));
492
+ ok(typeof valid === 'string', 'invalid: a refused create writes nothing');
493
+ await rejects(() => store.updateScheduleEntry({
494
+ player: 'p-bad',
495
+ id: 'reminder:bad',
496
+ version: `${valid ?? ''}-stale`,
497
+ entry: bad,
498
+ }), 'invalid: a due time that does not parse is refused before the version is compared');
499
+ await rejects(() => store.listDueScheduleEntries({ at: 'not-a-time', limit: 1 }), 'invalid: a due listing time that does not parse');
500
+ await rejects(() => store.countHistory({ provider: 'paced', since: 'not-a-time', until: t(0) }), 'invalid: a count start that does not parse');
501
+ await rejects(() => store.countHistory({ provider: 'paced', since: t(0), until: 'not-a-time' }), 'invalid: a count end that does not parse');
502
+ const consentBefore = await store.listConsent('p-prune');
503
+ const givenBefore = await store.listNoticesGiven('p-prune');
504
+ ok(consentBefore.length > 0 && givenBefore.length > 0, 'invalid: records and a notice to keep');
505
+ const instants = consentBefore.map((record) => record.at);
506
+ // Every record (one with a notice given) named next to an instant that does not parse, last and first.
507
+ for (const pruneAt of [
508
+ [...instants, 'not-a-time'],
509
+ ['not-a-time', ...instants],
510
+ ]) {
511
+ await rejects(() => store.pruneConsent({ player: 'p-prune', at: pruneAt }), 'invalid: a pruned instant that does not parse');
512
+ deepEqual([await store.listConsent('p-prune'), await store.listNoticesGiven('p-prune')], [consentBefore, givenBefore], 'invalid: a refused pruning deletes nothing, not even the records and notices that parse');
513
+ }
514
+ // Pending reminders per player: reminders without firedAt, under the reminder: prefix.
515
+ equal(await store.countPendingReminders('p-count'), 0, 'count: none yet');
516
+ for (const id of ['reminder:a', 'reminder:b', 'reminder:c']) {
517
+ await store.createScheduleEntry(entry('p-count', id, t(9_000)));
518
+ }
519
+ await store.createScheduleEntry({
520
+ player: 'p-count',
521
+ id: 'notices',
522
+ dueAt: t(9_000),
523
+ item: { kind: 'notices', attempts: 0 },
524
+ });
525
+ await store.createScheduleEntry(entry('p-count-other', 'reminder:a', t(9_000)));
526
+ const firing = await store.getScheduleEntry({ player: 'p-count', id: 'reminder:c' });
527
+ ok(firing !== undefined && await store.updateScheduleEntry({
528
+ player: 'p-count',
529
+ id: 'reminder:c',
530
+ version: firing.version,
531
+ entry: { ...firing.entry, item: { ...first.item, key: 'c', firedAt: t(9_000) } },
532
+ }), 'count: a reminder is marked fired');
533
+ equal(await store.countPendingReminders('p-count'), 2, 'count: pending reminders of the player only, not fired ones or the notice check');
534
+ }
535
+ /**
536
+ * The clauses a store can get wrong while passing the sequential ones:
537
+ * parallel callers of each atomic step, the dead-address and unbind edges,
538
+ * the due and in-flight indexes as rows change state, and purge boundaries.
539
+ */
540
+ async function runConcurrencyAndEdgeClauses(store, at, endpoint, send) {
541
+ const later = (offsetMs) => at(10 * 86_400_000 + offsetMs);
542
+ // Parallel callers of each atomic step.
543
+ const parallel = await Promise.all(Array.from({ length: 8 }, () => store.appendConsent({
544
+ player: 'p-parallel',
545
+ record: { kind: 'advertising', state: 'granted', source: 'player', textVersion: 'v1' },
546
+ at: later(0),
547
+ })));
548
+ deepEqual(parallel.map((record) => Date.parse(record.at)).sort((left, right) => left - right), Array.from({ length: 8 }, (_, index) => Date.parse(later(index))), 'concurrency: parallel consent records each take their own millisecond');
549
+ deepEqual((await store.listConsent('p-parallel')).map((record) => record.at), Array.from({ length: 8 }, (_, index) => later(index)), 'concurrency: parallel consent records are listed in time order');
550
+ await Promise.all([
551
+ store.putEndpoint(endpoint('race-1', 'tok-race', { player: 'p-race' })),
552
+ store.putEndpoint(endpoint('race-2', 'tok-race', { player: 'p-race' })),
553
+ ]);
554
+ equal((await store.listEndpoints({ player: 'p-race', provider: 'fcm' })).length, 1, 'concurrency: one address stays on one installation');
555
+ const racing = {
556
+ ...send,
557
+ key: 'race-send',
558
+ request: { ...send.request, idempotencyKey: 'race-send' },
559
+ };
560
+ const versions = await Promise.all(Array.from({ length: 4 }, () => store.createSend(racing)));
561
+ equal(versions.filter((version) => version !== undefined).length, 1, 'concurrency: a key is created once');
562
+ const racingVersion = (await store.getSend('race-send'))?.version ?? '';
563
+ const writes = await Promise.all(Array.from({ length: 4 }, () => store.updateSend({
564
+ key: 'race-send',
565
+ version: racingVersion,
566
+ send: { ...racing, changedAt: later(1) },
567
+ })));
568
+ equal(writes.filter(Boolean).length, 1, 'concurrency: one write wins a version');
569
+ // Dead addresses and unbinding.
570
+ await store.putEndpoint(endpoint('dead-1', 'tok-dead', { player: 'p-edge' }));
571
+ await store.invalidateEndpoint({
572
+ provider: 'fcm',
573
+ installationId: 'dead-1',
574
+ value: 'tok-dead',
575
+ at: later(5),
576
+ });
577
+ await store.invalidateEndpoint({
578
+ provider: 'fcm',
579
+ installationId: 'dead-1',
580
+ value: 'tok-dead',
581
+ at: later(9),
582
+ });
583
+ const reregistered = await store.putEndpoint(endpoint('dead-1', 'tok-dead', {
584
+ player: 'p-edge',
585
+ permission: 'blocked',
586
+ lastRegisteredAt: later(20),
587
+ }));
588
+ deepEqual([
589
+ reregistered.status,
590
+ reregistered.invalidatedAt,
591
+ reregistered.permission,
592
+ reregistered.lastRegisteredAt,
593
+ ], ['invalid', later(5), 'blocked', later(20)], 'endpoints: a dead address keeps its first invalidation and takes the new registration fields');
594
+ // Another installation that reports the dead address takes it, valid.
595
+ const taken = await store.putEndpoint(endpoint('dead-2', 'tok-dead', { player: 'p-edge' }));
596
+ equal(taken.status, 'valid', 'endpoints: another installation takes a dead address as valid');
597
+ equal(await store.getEndpoint({ provider: 'fcm', installationId: 'dead-1' }), undefined, 'endpoints: the dead copy goes');
598
+ await store.invalidateEndpoint({
599
+ provider: 'fcm',
600
+ installationId: 'dead-2',
601
+ value: 'tok-dead',
602
+ at: later(40),
603
+ });
604
+ const revived = await store.putEndpoint(endpoint('dead-2', 'tok-new', { player: 'p-edge' }));
605
+ deepEqual([revived.status, revived.invalidatedAt], ['valid', undefined], 'endpoints: a revived endpoint is valid, without an invalidation time');
606
+ await store.putEndpoint({
607
+ ...endpoint('multi', 'tok-m1', { player: 'p-edge' }),
608
+ provider: 'fcm',
609
+ });
610
+ await store.putEndpoint({
611
+ ...endpoint('multi', 'tok-m2', { player: 'p-edge' }),
612
+ provider: 'other',
613
+ });
614
+ await store.removeEndpoints({ installationId: 'multi', player: 'p-edge', provider: 'other' });
615
+ ok((await store.getEndpoint({ provider: 'fcm', installationId: 'multi' })) !== undefined
616
+ && (await store.getEndpoint({ provider: 'other', installationId: 'multi' })) === undefined, 'endpoints: an unbind of one provider removes it and keeps the other');
617
+ await store.putEndpoint({
618
+ ...endpoint('multi', 'tok-m2', { player: 'p-edge' }),
619
+ provider: 'other',
620
+ });
621
+ await store.removeEndpoints({ installationId: 'multi', player: 'p-edge' });
622
+ ok((await store.getEndpoint({ provider: 'fcm', installationId: 'multi' })) === undefined
623
+ && (await store.getEndpoint({ provider: 'other', installationId: 'multi' })) === undefined, 'endpoints: an unbind without a provider removes every provider');
624
+ // Notices are named by instant and per player.
625
+ const notice = {
626
+ kind: 'reconfirmation',
627
+ record: { kind: 'night', at: '2026-10-19T03:00:00Z' },
628
+ dueBy: '2028-10-18',
629
+ player: 'p-edge',
630
+ channel: 'in-game',
631
+ at: later(0),
632
+ };
633
+ await store.addNoticeGiven(notice);
634
+ await store.addNoticeGiven({
635
+ ...notice,
636
+ record: { kind: 'night', at: '2026-10-19T12:00:00+09:00' },
637
+ });
638
+ await store.addNoticeGiven({ ...notice, player: 'p-other' });
639
+ equal((await store.listNoticesGiven('p-edge')).length, 1, 'notices: another spelling of the instant is the same notice');
640
+ equal((await store.listNoticesGiven('p-other')).length, 1, 'notices: the same name for another player is recorded');
641
+ // The due and in-flight indexes follow the rows.
642
+ const edge = (key, rows, extra = {}) => ({
643
+ ...send,
644
+ key,
645
+ request: { ...send.request, idempotencyKey: key, player: 'p-index' },
646
+ rows,
647
+ changedAt: later(0),
648
+ ...extra,
649
+ });
650
+ const queued = edge('idx-queued', [
651
+ {
652
+ key: 'idx-queued',
653
+ provider: 'platform',
654
+ state: { status: 'in-flight', leaseUntil: later(300), attempted: false },
655
+ },
656
+ ]);
657
+ const mixed = edge('idx-mixed', [
658
+ { key: 'idx-mixed:a', provider: 'fcm', installationId: 'a', state: { status: 'delivered' } },
659
+ {
660
+ key: 'idx-mixed',
661
+ provider: 'platform',
662
+ firstAttemptedAt: later(0),
663
+ state: {
664
+ status: 'in-flight',
665
+ leaseUntil: later(400),
666
+ claim: 'c',
667
+ attempted: true,
668
+ attemptedAt: later(0),
669
+ },
670
+ },
671
+ ]);
672
+ const abandoned = edge('idx-abandoned', [
673
+ {
674
+ key: 'idx-abandoned',
675
+ provider: 'platform',
676
+ firstAttemptedAt: later(0),
677
+ state: {
678
+ status: 'in-flight',
679
+ leaseUntil: later(86_400_000),
680
+ claim: 'c',
681
+ attempted: true,
682
+ attemptedAt: later(86_100_000),
683
+ },
684
+ },
685
+ ]);
686
+ const claimOnly = edge('idx-claim', [], { claim: { token: 't', leaseUntil: later(300) } });
687
+ for (const created of [queued, mixed, abandoned, claimOnly]) {
688
+ await store.createSend(created);
689
+ }
690
+ deepEqual(await store.getSend('idx-mixed').then((held) => held?.send), mixed, 'sends: attempt times round-trip');
691
+ ok(!(await store.listDueSends({ at: later(299), limit: 100 })).includes('idx-queued'), 'sends: a queued row is not due before its lease ends');
692
+ deepEqual([...await store.listDueSends({ at: later(2 * 86_400_000), limit: 100 })].filter((key) => key.startsWith('idx-')).sort(), ['idx-mixed', 'idx-queued'], 'sends: a queued and an attempted row are due at their lease; an abandoned row and a bare claim never');
693
+ deepEqual((await store.listInFlightSends('p-index')).map((row) => row.key).sort(), ['idx-abandoned', 'idx-mixed', 'idx-queued'], 'sends: a send with any in-flight row is in flight, even next to a delivered row');
694
+ const settled = await store.getSend('idx-mixed');
695
+ await store.updateSend({
696
+ key: 'idx-mixed',
697
+ version: settled?.version ?? '',
698
+ send: { ...mixed, rows: [mixed.rows[0]] },
699
+ });
700
+ ok(!(await store.listDueSends({ at: later(2 * 86_400_000), limit: 100 })).includes('idx-mixed')
701
+ && !(await store.listInFlightSends('p-index')).some((row) => row.key === 'idx-mixed'), 'sends: a send whose rows all delivered leaves both indexes');
702
+ // History keeps one entry per send and provider.
703
+ await store.addHistory({
704
+ sendKey: 'two-providers',
705
+ provider: 'fcm',
706
+ player: 'p-edge',
707
+ topic: 't',
708
+ at: later(1),
709
+ });
710
+ await store.addHistory({
711
+ sendKey: 'two-providers',
712
+ provider: 'other',
713
+ player: 'p-edge',
714
+ topic: 't',
715
+ at: later(2),
716
+ });
717
+ equal((await store.listHistory({ player: 'p-edge', since: later(0) })).length, 2, 'history: another provider is another entry');
718
+ // Purge cutoffs are exclusive, and records outside purge survive it.
719
+ await store.putEndpoint(endpoint('edge-invalid', 'tok-edge', { player: 'p-edge', lastRegisteredAt: later(0) }));
720
+ await store.invalidateEndpoint({
721
+ provider: 'fcm',
722
+ installationId: 'edge-invalid',
723
+ value: 'tok-edge',
724
+ at: later(50),
725
+ });
726
+ await store.putEndpoint(endpoint('edge-session', 'tok-es', {
727
+ player: 'p-edge',
728
+ lastRegisteredAt: later(0),
729
+ sessionExpiresAt: later(0),
730
+ }));
731
+ await store.putPlatformRegistration({
732
+ player: 'p-edge',
733
+ provider: 'ait',
734
+ registeredAt: later(0),
735
+ });
736
+ await store.purge({
737
+ sendsChangedBefore: later(0),
738
+ historyBefore: later(1),
739
+ invalidEndpointsBefore: later(50),
740
+ staleEndpointsBefore: later(0),
741
+ sessionEndpointsBefore: later(0),
742
+ });
743
+ ok((await store.getSend('idx-queued')) !== undefined, 'purge: a send changed at the cutoff is kept');
744
+ ok((await store.getEndpoint({ provider: 'fcm', installationId: 'edge-invalid' })) !== undefined, 'purge: an endpoint invalidated, and last registered, at the cutoffs is kept');
745
+ ok((await store.getEndpoint({ provider: 'fcm', installationId: 'edge-session' })) !== undefined, 'purge: an endpoint whose session ends at the cutoff is kept');
746
+ equal((await store.listHistory({ player: 'p-edge', since: later(0) })).length, 2, 'purge: history at the cutoff is kept');
747
+ equal((await store.listConsent('p-parallel')).length, 8, 'purge: consent records are not purged');
748
+ equal((await store.listNoticesGiven('p-edge')).length, 1, 'purge: notices given are not purged');
749
+ ok((await store.getPlatformRegistration({ player: 'p-edge', provider: 'ait' })) !== undefined, 'purge: registrations are not purged');
750
+ }
751
+ /** Expects `work` to reject with an error (any store's own error type and wording). */
752
+ async function rejects(work, message) {
753
+ let outcome = 'resolved';
754
+ try {
755
+ await work();
756
+ }
757
+ catch (error) {
758
+ if (error instanceof Error) {
759
+ return;
760
+ }
761
+ outcome = error;
762
+ }
763
+ fail(message, outcome, 'a rejection with an Error');
764
+ }
765
+ function fail(message, actual, expected) {
766
+ throw new Error(`Notification store conformance: ${message}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`);
767
+ }
768
+ function ok(value, message) {
769
+ if (!value) {
770
+ fail(message, value, true);
771
+ }
772
+ }
773
+ function equal(actual, expected, message) {
774
+ if (!Object.is(actual, expected)) {
775
+ fail(message, actual, expected);
776
+ }
777
+ }
778
+ function deepEqual(actual, expected, message) {
779
+ if (canonical(actual) !== canonical(expected)) {
780
+ fail(message, actual, expected);
781
+ }
782
+ }
783
+ function canonical(value) {
784
+ if (Array.isArray(value)) {
785
+ return `[${value.map(canonical).join(',')}]`;
786
+ }
787
+ if (typeof value === 'object' && value !== null) {
788
+ return `{${Object.keys(value)
789
+ .filter((key) => value[key] !== undefined)
790
+ .sort()
791
+ .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
792
+ .join(',')}}`;
793
+ }
794
+ return JSON.stringify(value) ?? 'undefined';
795
+ }