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
+71 -6
View File
@@ -66,6 +66,13 @@ export interface RealtimeReadModelBaseline {
nations: Map<number, ReadModelSignatures>;
}
export type PersistedVisibleLogRow = {
id: number;
scope: LogScope;
category: LogCategory;
generalId: number | null;
};
const canonicalizeReadModelValue = (value: unknown): unknown => {
if (value instanceof Date) {
return value.toISOString();
@@ -321,6 +328,31 @@ export const summarizeRealtimeReadModelChanges = (
};
};
export const mergePersistedVisibleLogChanges = (
changes: RealtimeReadModelChanges,
rows: readonly PersistedVisibleLogRow[]
): RealtimeReadModelChanges => ({
...changes,
recordGeneralIds: uniqueSortedIds([
...changes.recordGeneralIds,
...rows.flatMap((entry) =>
entry.scope === LogScope.GENERAL && entry.category === LogCategory.ACTION && entry.generalId
? [entry.generalId]
: []
),
]),
globalRecordsChanged:
changes.globalRecordsChanged ||
rows.some(
(entry) =>
entry.scope === LogScope.SYSTEM &&
(entry.category === LogCategory.SUMMARY || entry.category === LogCategory.ACTION)
),
worldHistoryChanged:
changes.worldHistoryChanged ||
rows.some((entry) => entry.scope === LogScope.SYSTEM && entry.category === LogCategory.HISTORY),
});
export const excludeDeletedReservedTurnQueues = (
changes: ReservedTurnChanges,
deletedGeneralIds: readonly number[],
@@ -871,10 +903,13 @@ export const createDatabaseTurnHooks = async (
const persistChanges = async (
transaction?: GamePrisma.TransactionClient,
commandCompletion?: { requestId: string; result: TurnDaemonCommandResult }
commandCompletion?: { requestId: string; result: TurnDaemonCommandResult },
directLogFloor?: number
): Promise<{ acknowledge: () => void; readModelChanges: RealtimeReadModelChanges }> => {
const state = world.getState();
const changes = world.peekDirtyState();
let persistedVisibleLogs: PersistedVisibleLogRow[] = [];
let visibleLogFloor = directLogFloor;
const {
generals,
cities,
@@ -918,6 +953,13 @@ export const createDatabaseTurnHooks = async (
meta: asJson(state.meta),
};
const persist = async (prisma: GamePrisma.TransactionClient): Promise<void> => {
visibleLogFloor ??=
(
await prisma.logEntry.findFirst({
orderBy: { id: 'desc' },
select: { id: true },
})
)?.id ?? 0;
// Lock and validate the fencing row in the same transaction as every
// world mutation. A stale daemon can finish calculating, but it can
// never commit after another owner has advanced the epoch.
@@ -1329,6 +1371,23 @@ export const createDatabaseTurnHooks = async (
},
});
}
persistedVisibleLogs = await prisma.logEntry.findMany({
where: {
id: { gt: visibleLogFloor },
OR: [
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
},
{
scope: LogScope.SYSTEM,
category: { in: [LogCategory.SUMMARY, LogCategory.ACTION, LogCategory.HISTORY] },
},
],
},
orderBy: { id: 'asc' },
select: { id: true, scope: true, category: true, generalId: true },
});
};
if (transaction) {
await persist(transaction);
@@ -1339,10 +1398,9 @@ export const createDatabaseTurnHooks = async (
);
}
const readModelChanges = summarizeRealtimeReadModelChanges(
changes,
persistedReservedTurnChanges,
readModelBaseline
const readModelChanges = mergePersistedVisibleLogChanges(
summarizeRealtimeReadModelChanges(changes, persistedReservedTurnChanges, readModelBaseline),
persistedVisibleLogs
);
return {
acknowledge: () => {
@@ -1370,8 +1428,15 @@ export const createDatabaseTurnHooks = async (
executeCommand: async (requestId, execute) => {
const committed = await prisma.$transaction(
async (transaction) => {
const directLogFloor =
(
await transaction.logEntry.findFirst({
orderBy: { id: 'desc' },
select: { id: true },
})
)?.id ?? 0;
const result = await execute({ db: transaction });
const persisted = await persistChanges(transaction, { requestId, result });
const persisted = await persistChanges(transaction, { requestId, result }, directLogFloor);
return { result, persisted };
},
options?.transactionTimeoutMs ? { timeout: options.transactionTimeoutMs } : undefined
@@ -271,6 +271,7 @@ integration('monthly nation betting persistence', () => {
durationMs: 0,
partial: false,
});
expect(hooks.takeCommittedReadModelChanges()?.worldHistoryChanged).toBe(true);
expect(await db.nationBetting.findUniqueOrThrow({ where: { id: bettingId } })).toMatchObject({
name: '천통국 예상',
@@ -320,6 +321,7 @@ integration('monthly nation betting persistence', () => {
})
).toBe(true);
await world.advanceMonth(new Date('0200-02-01T00:00:00.000Z'));
expect(world.peekDirtyState().logs).toEqual([]);
await hooks.hooks.flushChanges?.({
lastTurnTime: state.lastTurnTime.toISOString(),
processedGenerals: 0,
@@ -327,6 +329,7 @@ integration('monthly nation betting persistence', () => {
durationMs: 0,
partial: false,
});
expect(hooks.takeCommittedReadModelChanges()?.worldHistoryChanged).toBe(true);
expect(await db.nationBetting.findUniqueOrThrow({ where: { id: bettingId } })).toMatchObject({
finished: true,
@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest';
import { createEmptyRealtimeReadModelChanges } from '@sammo-ts/common';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import {
applyRealtimeReadModelBaseline,
createRealtimeReadModelBaseline,
mergePersistedVisibleLogChanges,
summarizeRealtimeReadModelChanges,
} from '../src/turn/databaseHooks.js';
import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
@@ -11,6 +13,35 @@ import type { TurnWorldChanges } from '../src/turn/inMemoryWorld.js';
import type { ReservedTurnChanges } from '../src/turn/reservedTurnStore.js';
describe('summarizeRealtimeReadModelChanges', () => {
it('classifies the committed log rows even when they bypass in-memory log drafts', () => {
expect(
mergePersistedVisibleLogChanges(createEmptyRealtimeReadModelChanges(), [
{
id: 10,
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: 7,
},
{
id: 11,
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
generalId: null,
},
{
id: 12,
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
generalId: null,
},
])
).toMatchObject({
recordGeneralIds: [7],
globalRecordsChanged: true,
worldHistoryChanged: true,
});
});
it('emits deterministic entity and record invalidations from committed changes', () => {
const worldChanges = {
generals: [{ id: 9 }, { id: 7 }],