fix(realtime): invalidate committed global activity logs

This commit is contained in:
2026-08-12 16:45:48 +00:00
parent 719c0c0e35
commit d1efa425eb
6 changed files with 286 additions and 13 deletions
@@ -0,0 +1,63 @@
import { randomUUID } from 'node:crypto';
import { buildGameEventChannel, createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common';
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
import { describe, expect, it } from 'vitest';
import { RedisRealtimeEventHub } from '../src/realtime/eventHub.js';
import { publishRealtimeEvent } from '../src/realtime/publisher.js';
const liveDescribe = process.env.REDIS_URL ? describe : describe.skip;
liveDescribe('realtime event hub with live Redis', () => {
it('forwards committed global record, history, and month-boundary flags intact', async () => {
const publisher = createRedisConnector(resolveRedisConfigFromEnv());
const subscriber = createRedisConnector(resolveRedisConfigFromEnv());
await Promise.all([publisher.connect(), subscriber.connect()]);
const runId = process.env.CONDITIONAL_INTEGRATION_RUN_ID ?? randomUUID();
const profileName = `hwe:global-events-${runId}-${randomUUID()}`;
const hub = new RedisRealtimeEventHub(subscriber.client, buildGameEventChannel(profileName));
let unsubscribe = () => {};
try {
const received = new Promise<RealtimeEvent>((resolve) => {
unsubscribe = hub.subscribe(resolve);
});
await hub.start();
await publishRealtimeEvent(publisher.client, profileName, {
type: 'turnCompleted',
at: '2026-08-12T00:00:00.000Z',
lastTurnTime: '0185-02-01T00:00:00.000Z',
changes: {
...createEmptyRealtimeReadModelChanges(),
worldChanged: true,
globalRecordsChanged: true,
worldHistoryChanged: true,
},
revision: 12,
});
await expect(
Promise.race([
received,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Redis realtime event timeout')), 2_000)
),
])
).resolves.toMatchObject({
type: 'turnCompleted',
changes: {
worldChanged: true,
globalRecordsChanged: true,
worldHistoryChanged: true,
},
revision: 12,
});
} finally {
unsubscribe();
await hub.stop();
await publisher.disconnect();
}
});
});
@@ -1,10 +1,12 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { RANK_DATA_TYPES } from '@sammo-ts/common';
import { buildGameEventChannel, RANK_DATA_TYPES, type RealtimeEvent } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
import {
createGamePostgresConnector,
createRedisConnector,
resolveRedisConfigFromEnv,
type GamePrisma,
type GamePrismaClient,
type RedisConnector,
@@ -16,6 +18,7 @@ import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.j
import type { GameApiContext } from '../src/context.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { RedisRealtimeEventHub } from '../src/realtime/eventHub.js';
import { appRouter } from '../src/router.js';
const databaseUrl = process.env.SELECT_POOL_DATABASE_URL;
@@ -92,6 +95,22 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
let daemonLoop: Promise<void> | undefined;
let turnDaemon: TurnDaemonTransport;
let worldStateId: number;
let realtimeHub: RedisRealtimeEventHub | undefined;
let unsubscribeRealtime = () => {};
const realtimeEvents: RealtimeEvent[] = [];
const waitForRealtimeEvent = async (
predicate: (event: RealtimeEvent) => boolean,
timeoutMs = 2_000
): Promise<RealtimeEvent> => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const event = realtimeEvents.find(predicate);
if (event) return event;
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error('Timed out waiting for select-pool realtime event.');
};
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => {
const redisClient = {
@@ -148,6 +167,14 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
await db.logEntry.deleteMany();
worldStateId = (await db.worldState.findFirstOrThrow()).id;
if (process.env.REDIS_URL) {
const realtimeSubscriber = createRedisConnector(resolveRedisConfigFromEnv());
await realtimeSubscriber.connect();
realtimeHub = new RedisRealtimeEventHub(realtimeSubscriber.client, buildGameEventChannel(profile));
unsubscribeRealtime = realtimeHub.subscribe((event) => realtimeEvents.push(event));
await realtimeHub.start();
}
runtime = await createTurnDaemonRuntime({
profile,
databaseUrl: databaseUrl!,
@@ -168,6 +195,8 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
await daemonLoop;
await runtime.close();
}
unsubscribeRealtime();
await realtimeHub?.stop();
await closeDb?.();
}, 30_000);
@@ -267,6 +296,18 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
where: { meta: { path: ['ownerUserId'], equals: userId } },
})
).toBe(2);
if (realtimeHub) {
const creationEvent = await waitForRealtimeEvent(
(event) => event.type === 'readModelChanged' && event.changes.globalRecordsChanged
);
expect(creationEvent).toMatchObject({
type: 'readModelChanged',
changes: {
generalIds: [initial.id],
globalRecordsChanged: true,
},
});
}
await expect(
appRouter.createCaller(buildContext('select-pool-cooldown')).join.getSelectionPool()