fix(realtime): invalidate committed global activity logs
This commit is contained in:
@@ -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 }],
|
||||
|
||||
Reference in New Issue
Block a user