feat: 계정 Web Push 전달 기반과 사건 판정을 추가한다

게임 상태 변경과 같은 트랜잭션에 내구성 outbox를 기록하고 Gateway가 계정 설정과 기기 구독으로 fan-out하도록 한다. 전역 기본값은 비활성으로 유지하며 migration과 회귀 테스트를 함께 추가한다.
This commit is contained in:
2026-08-23 13:16:07 +00:00
parent f858806295
commit 7bc3213b03
34 changed files with 1964 additions and 9 deletions
+1
View File
@@ -47,4 +47,5 @@ export interface DatabaseClient {
inputEvent: GamePrisma.InputEventDelegate;
turnDaemonLease: GamePrisma.TurnDaemonLeaseDelegate;
readModelOutbox: GamePrisma.ReadModelOutboxDelegate;
webPushOutbox: GamePrisma.WebPushOutboxDelegate;
}
+1
View File
@@ -10,3 +10,4 @@ export * from './readModelChangeJournal.js';
export * from './readModelOutboxDispatcher.js';
export * from './readModelCoverageActivation.js';
export * from './gameSchemaAdvisoryLock.js';
export * from './webPushOutbox.js';
+53
View File
@@ -0,0 +1,53 @@
import type { WebPushEventType } from '@sammo-ts/common';
import type { GamePrisma } from './gamePrisma.js';
export type WebPushOutboxDatabase = Pick<GamePrisma.TransactionClient, 'general' | 'webPushOutbox'>;
export interface WebPushOutboxEventInput {
eventId: string;
eventType: WebPushEventType;
userIds?: readonly string[];
year?: number;
month?: number;
}
const uniqueUserIds = (values: readonly string[]): string[] => [...new Set(values.filter(Boolean))].sort();
export const enqueueWebPushOutboxEvents = async (
db: WebPushOutboxDatabase,
events: readonly WebPushOutboxEventInput[]
): Promise<number> => {
if (events.length === 0) return 0;
const result = await db.webPushOutbox.createMany({
data: events.map((event) => ({
eventId: event.eventId,
eventType: event.eventType,
userIds: uniqueUserIds(event.userIds ?? []),
...(event.year === undefined ? {} : { year: event.year }),
...(event.month === undefined ? {} : { month: event.month }),
})),
skipDuplicates: true,
});
return result.count;
};
export const enqueuePrivateMessageWebPush = async (
db: WebPushOutboxDatabase,
draft: { msgType: string; mailbox: number; destId: number },
messageId: number
): Promise<void> => {
if (draft.msgType !== 'private' || draft.mailbox !== draft.destId) return;
const recipient = await db.general.findUnique({
where: { id: draft.destId },
select: { userId: true },
});
if (!recipient?.userId) return;
await enqueueWebPushOutboxEvents(db, [
{
eventId: `message:${messageId}`,
eventType: 'PRIVATE_MESSAGE_RECEIVED',
userIds: [recipient.userId],
},
]);
};