refactor(game): invalidate field-level read-model projections
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common';
|
||||
import {
|
||||
buildGameEventChannel,
|
||||
buildGameReadModelRevisionKey,
|
||||
type RealtimeEvent,
|
||||
type RealtimeReadModelChanges,
|
||||
} from '@sammo-ts/common';
|
||||
|
||||
// 게임 서버의 실시간 이벤트를 Redis pub/sub 채널로 송신한다.
|
||||
export const publishRealtimeEvent = async (
|
||||
@@ -10,3 +15,18 @@ export const publishRealtimeEvent = async (
|
||||
const channel = buildGameEventChannel(profileName);
|
||||
await redis.publish(channel, JSON.stringify(event));
|
||||
};
|
||||
|
||||
export const publishRealtimeReadModelChanges = async (
|
||||
redis: RedisConnector['client'],
|
||||
profileName: string,
|
||||
changes: RealtimeReadModelChanges
|
||||
): Promise<number> => {
|
||||
const revision = await redis.incr(buildGameReadModelRevisionKey(profileName));
|
||||
await publishRealtimeEvent(redis, profileName, {
|
||||
type: 'readModelChanged',
|
||||
at: new Date().toISOString(),
|
||||
changes,
|
||||
revision,
|
||||
});
|
||||
return revision;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { asRecord, createEmptyRealtimeReadModelChanges, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import {
|
||||
ITEM_KEYS,
|
||||
@@ -17,8 +17,24 @@ import {
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
|
||||
import { publishRealtimeReadModelChanges } from '../../realtime/publisher.js';
|
||||
|
||||
const publishFrontStatusChange = async (
|
||||
ctx: GameApiContext,
|
||||
options: { generalId?: number; global?: boolean }
|
||||
): Promise<void> => {
|
||||
const changes = createEmptyRealtimeReadModelChanges();
|
||||
if (options.generalId) changes.frontStatusActorIds = [options.generalId];
|
||||
if (options.global) changes.frontStatusChanged = true;
|
||||
try {
|
||||
await publishRealtimeReadModelChanges(ctx.redis, ctx.profile.name, changes);
|
||||
} catch {
|
||||
// 설문 DB mutation은 이미 commit되었으므로 실시간 알림 실패로 되돌리지 않는다.
|
||||
}
|
||||
};
|
||||
|
||||
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
||||
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
||||
@@ -507,6 +523,7 @@ export const voteRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason });
|
||||
}
|
||||
|
||||
await publishFrontStatusChange(ctx, { generalId: general.id });
|
||||
return { ok: true, wonLottery: rewardResult.awardedUnique };
|
||||
}),
|
||||
addComment: authedProcedure
|
||||
@@ -617,6 +634,7 @@ export const voteRouter = router({
|
||||
)
|
||||
`);
|
||||
|
||||
await publishFrontStatusChange(ctx, { global: true });
|
||||
return { ok: true };
|
||||
}),
|
||||
updatePoll: adminProcedure
|
||||
@@ -713,6 +731,9 @@ export const voteRouter = router({
|
||||
WHERE id = ${input.voteId}
|
||||
`);
|
||||
|
||||
if (input.title !== undefined || endAt !== undefined) {
|
||||
await publishFrontStatusChange(ctx, { global: true });
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
closePoll: adminProcedure
|
||||
@@ -727,6 +748,7 @@ export const voteRouter = router({
|
||||
if (!rows[0]?.id) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '설문조사가 없습니다.' });
|
||||
}
|
||||
await publishFrontStatusChange(ctx, { global: true });
|
||||
return { ok: true };
|
||||
}),
|
||||
getAdminStatus: adminProcedure.query(async () => ({ ok: true })),
|
||||
|
||||
@@ -104,6 +104,8 @@ const buildContext = (options: {
|
||||
generalId: general?.id ?? 0,
|
||||
awardedUnique: false,
|
||||
}));
|
||||
const redisIncr = vi.fn(async (_key: string) => 41);
|
||||
const redisPublish = vi.fn(async (_channel: string, _message: string) => 1);
|
||||
const queryRaw = vi.fn(async (query: GamePrisma.Sql) => {
|
||||
const text = sqlText(query);
|
||||
if (text.includes('FROM vote_poll') && text.includes('LIMIT 1')) {
|
||||
@@ -177,7 +179,10 @@ const buildContext = (options: {
|
||||
);
|
||||
const context: GameApiContext = {
|
||||
db: db as unknown as DatabaseClient,
|
||||
redis: {} as RedisConnector['client'],
|
||||
redis: {
|
||||
incr: redisIncr,
|
||||
publish: redisPublish,
|
||||
} as unknown as RedisConnector['client'],
|
||||
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
@@ -189,7 +194,7 @@ const buildContext = (options: {
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
return { context, requestCommand, queryRaw, db };
|
||||
return { context, requestCommand, queryRaw, db, redisIncr, redisPublish };
|
||||
};
|
||||
|
||||
describe('vote router actor and permission boundaries', () => {
|
||||
@@ -216,6 +221,34 @@ describe('vote router actor and permission boundaries', () => {
|
||||
goldReward: 90,
|
||||
})
|
||||
);
|
||||
expect(fixture.redisIncr).toHaveBeenCalledWith('sammo:che:default:read-model:revision');
|
||||
const published = JSON.parse(String(fixture.redisPublish.mock.calls[0]?.[1]));
|
||||
expect(published).toMatchObject({
|
||||
type: 'readModelChanged',
|
||||
revision: 41,
|
||||
changes: {
|
||||
frontStatusActorIds: [7],
|
||||
frontStatusChanged: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes a global front-status projection after creating a survey', async () => {
|
||||
const fixture = buildContext({ auth: buildAuth(['admin.survey.open']) });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).vote.createPoll({
|
||||
title: '새 설문',
|
||||
options: ['찬성', '반대'],
|
||||
revealMode: 'after_vote',
|
||||
})
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
const published = JSON.parse(String(fixture.redisPublish.mock.calls[0]?.[1]));
|
||||
expect(published).toMatchObject({
|
||||
type: 'readModelChanged',
|
||||
changes: { frontStatusChanged: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the current world develcost for the legacy five-times survey reward', async () => {
|
||||
|
||||
Reference in New Issue
Block a user