feat: 엔진 변화 저널을 상태 커밋과 원자화

최종 read model projection과 저장된 로그를 소유 transaction 안에서 판정하고 revision 및 outbox와 함께 커밋한다. clock 전용 변화와 rollback은 revision을 남기지 않으며 커밋 receipt를 유실 없이 큐잉한다.
This commit is contained in:
2026-08-16 18:18:08 +00:00
parent 8278ef5221
commit b41b161e9c
4 changed files with 546 additions and 34 deletions
@@ -0,0 +1,267 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { TurnDaemonCommandResult, TurnRunResult } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import { LogCategory, LogFormat, LogScope, type MapDefinition, type ScenarioConfig } from '@sammo-ts/logic';
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
const databaseUrl = process.env.READ_MODEL_JOURNAL_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const worldId = 991_816;
const directLogGeneralId = 991_817;
const requestId = 'integration:engine:read-model-journal:direct-logs';
const rollbackConstraint = 'read_model_outbox_engine_rollback_test';
const assertDedicatedDatabase = (rawUrl: string): void => {
const schema = new URL(rawUrl).searchParams.get('schema');
if (!schema?.endsWith('read_model_journal_integration')) {
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
}
};
const scenarioConfig: ScenarioConfig = {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
iconPath: '',
map: {},
const: { feature: 'stable' },
environment: { mapName: 'che', unitSet: 'che' },
};
const map: MapDefinition = {
id: 'read-model-journal-integration',
name: 'read-model journal integration',
cities: [],
};
const initialState: TurnWorldState = {
id: worldId,
currentYear: 190,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('2026-08-16T00:00:00.000Z'),
meta: {
lastTurnTime: '2026-08-16T00:00:00.000Z',
scenarioMeta: {
title: 'read-model journal integration',
startYear: 190,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
},
},
};
const turnRunResult = (world: InMemoryTurnWorld): TurnRunResult => ({
lastTurnTime: world.getState().lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
});
integration('game-engine read-model journal PostgreSQL transaction', () => {
let db: GamePrismaClient;
let disconnect: (() => Promise<void>) | undefined;
let hooks: DatabaseTurnHooks | undefined;
beforeAll(async () => {
if (!databaseUrl) {
throw new Error('READ_MODEL_JOURNAL_DATABASE_URL is required.');
}
assertDedicatedDatabase(databaseUrl);
const connector = createGamePostgresConnector({ url: databaseUrl });
db = connector.prisma;
disconnect = connector.disconnect;
await connector.connect();
await db.$executeRawUnsafe(`ALTER TABLE read_model_outbox DROP CONSTRAINT IF EXISTS ${rollbackConstraint}`);
await db.inputEvent.deleteMany({ where: { requestId } });
await db.logEntry.deleteMany({ where: { text: { startsWith: '[read-model-journal]' } } });
await db.worldState.deleteMany({ where: { id: worldId } });
await db.$executeRaw`TRUNCATE TABLE "read_model_outbox", "read_model_revision" RESTART IDENTITY`;
await db.worldState.create({
data: {
id: worldId,
scenarioCode: 'read-model-journal-integration',
currentYear: initialState.currentYear,
currentMonth: initialState.currentMonth,
tickSeconds: initialState.tickSeconds,
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: initialState.meta as GamePrisma.InputJsonValue,
},
});
});
afterAll(async () => {
await hooks?.close();
if (db) {
await db.$executeRawUnsafe(
`ALTER TABLE read_model_outbox DROP CONSTRAINT IF EXISTS ${rollbackConstraint}`
);
await db.inputEvent.deleteMany({ where: { requestId } });
await db.logEntry.deleteMany({ where: { text: { startsWith: '[read-model-journal]' } } });
await db.worldState.deleteMany({ where: { id: worldId } });
await db.$executeRaw`TRUNCATE TABLE "read_model_outbox", "read_model_revision" RESTART IDENTITY`;
}
await disconnect?.();
});
it('commits visible state/log domains atomically and excludes clock-only or rolled-back work', async () => {
const snapshot: TurnWorldSnapshot = {
generals: [],
cities: [],
nations: [],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
scenarioConfig,
scenarioMeta: initialState.meta.scenarioMeta as TurnWorldSnapshot['scenarioMeta'],
map,
};
const world = new InMemoryTurnWorld(initialState, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
hooks = await createDatabaseTurnHooks(databaseUrl!, world);
world.setLastTurnTime(new Date('2026-08-16T00:10:00.000Z'));
await hooks.hooks.flushChanges?.(turnRunResult(world));
expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull();
await hooks.hooks.flushChanges?.(turnRunResult(world));
expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull();
await expect(db.readModelRevision.count()).resolves.toBe(0);
await expect(db.readModelOutbox.count()).resolves.toBe(0);
await db.$executeRawUnsafe(`
ALTER TABLE read_model_outbox
ADD CONSTRAINT ${rollbackConstraint}
CHECK ((payload->>'version')::integer <> 1)
`);
world.updateWorldMeta({ durableFixture: 'must-rollback' });
world.pushLog({
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
format: LogFormat.RAWTEXT,
text: '[read-model-journal] rollback',
});
await expect(hooks.hooks.flushChanges?.(turnRunResult(world))).rejects.toThrow(rollbackConstraint);
expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull();
await expect(db.logEntry.count({ where: { text: '[read-model-journal] rollback' } })).resolves.toBe(0);
await expect(db.readModelRevision.count()).resolves.toBe(0);
await expect(db.readModelOutbox.count()).resolves.toBe(0);
expect((await db.worldState.findUniqueOrThrow({ where: { id: worldId } })).meta).not.toMatchObject({
durableFixture: 'must-rollback',
});
await db.$executeRawUnsafe(`ALTER TABLE read_model_outbox DROP CONSTRAINT ${rollbackConstraint}`);
await hooks.hooks.flushChanges?.(turnRunResult(world));
const stateAndLogReceipt = hooks.takeCommittedReadModelChangeReceipt();
expect(stateAndLogReceipt?.changes).toMatchObject({
worldChanged: true,
globalRecordsChanged: true,
});
expect(stateAndLogReceipt?.invalidation.revisions).toEqual([
{ domain: 'map.world', entityId: 0, revision: 1n },
{ domain: 'records.global', entityId: 0, revision: 1n },
{ domain: 'world.content', entityId: 0, revision: 1n },
]);
await expect(db.logEntry.count({ where: { text: '[read-model-journal] rollback' } })).resolves.toBe(1);
await expect(db.readModelOutbox.count()).resolves.toBe(1);
await world.advanceMonth(new Date('2026-08-16T00:20:00.000Z'));
await hooks.hooks.flushChanges?.(turnRunResult(world));
const monthReceipt = hooks.takeCommittedReadModelChangeReceipt();
expect(monthReceipt?.changes.worldChanged).toBe(true);
expect(monthReceipt?.invalidation.revisions).toEqual([
{ domain: 'map.world', entityId: 0, revision: 2n },
{ domain: 'world.content', entityId: 0, revision: 2n },
]);
await expect(db.worldState.findUniqueOrThrow({ where: { id: worldId } })).resolves.toMatchObject({
currentYear: 190,
currentMonth: 2,
});
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: 'shiftSchedule',
status: 'PROCESSING',
payload: {},
},
});
const directResult: TurnDaemonCommandResult = {
type: 'shiftSchedule',
ok: true,
actionId: 'read-model-journal-direct-log',
deltaMinutes: 0,
lastTurnTime: world.getState().lastTurnTime.toISOString(),
shiftedGenerals: 0,
shiftedAuctions: 0,
};
await hooks.hooks.executeCommand?.(requestId, async ({ db: transaction }) => {
if (!transaction) {
throw new Error('Expected the direct-log command transaction.');
}
await transaction.logEntry.createMany({
data: [
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
year: 190,
month: 2,
text: '[read-model-journal] direct general',
generalId: directLogGeneralId,
},
{
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
year: 190,
month: 2,
text: '[read-model-journal] direct global',
},
{
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
year: 190,
month: 2,
text: '[read-model-journal] direct history',
},
],
});
return directResult;
});
const directLogReceipt = hooks.takeCommittedReadModelChangeReceipt();
expect(directLogReceipt?.changes).toMatchObject({
recordGeneralIds: [directLogGeneralId],
globalRecordsChanged: true,
worldHistoryChanged: true,
worldChanged: false,
});
expect(directLogReceipt?.invalidation.revisions).toEqual([
{ domain: 'records.general', entityId: directLogGeneralId, revision: 1n },
{ domain: 'records.global', entityId: 0, revision: 2n },
{ domain: 'records.history', entityId: 0, revision: 1n },
]);
await expect(db.readModelOutbox.count()).resolves.toBe(3);
await expect(db.logEntry.count({ where: { text: { startsWith: '[read-model-journal] direct' } } })).resolves.toBe(
3
);
world.updateWorldMeta({ queueProbe: 1 });
await hooks.hooks.flushChanges?.(turnRunResult(world));
world.updateWorldMeta({ queueProbe: 2 });
await hooks.hooks.flushChanges?.(turnRunResult(world));
const firstQueuedReceipt = hooks.takeCommittedReadModelChangeReceipt();
const secondQueuedReceipt = hooks.takeCommittedReadModelChangeReceipt();
expect(firstQueuedReceipt?.outboxId).toBeLessThan(secondQueuedReceipt?.outboxId ?? 0n);
expect(firstQueuedReceipt?.changes.worldChanged).toBe(true);
expect(secondQueuedReceipt?.changes.worldChanged).toBe(true);
expect(hooks.takeCommittedReadModelChangeReceipt()).toBeNull();
await expect(db.readModelOutbox.count()).resolves.toBe(5);
});
});
@@ -4,7 +4,9 @@ import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import {
applyRealtimeReadModelBaseline,
createReadModelChangeJournal,
createRealtimeReadModelBaseline,
createWorldReadModelSignature,
mergePersistedVisibleLogChanges,
summarizeRealtimeReadModelChanges,
} from '../src/turn/databaseHooks.js';
@@ -12,6 +14,98 @@ import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnWorldChanges } from '../src/turn/inMemoryWorld.js';
import type { ReservedTurnChanges } from '../src/turn/reservedTurnStore.js';
describe('durable read-model change journal mapping', () => {
it('maps every final engine invalidation to its precise durable domain', () => {
const changes = {
...createEmptyRealtimeReadModelChanges(),
generalIds: [7],
cityIds: [3],
nationIds: [2],
mapGeneralIds: [8],
mapCityIds: [4],
mapNationIds: [5],
frontStatusGeneralIds: [9],
frontStatusNationIds: [2],
frontStatusActorIds: [7],
lobbyGeneralIds: [7],
reservedGeneralIds: [7],
recordGeneralIds: [7],
worldChanged: true,
globalRecordsChanged: true,
worldHistoryChanged: true,
contactsChanged: true,
frontStatusChanged: true,
mapChanged: true,
lobbyChanged: true,
};
expect(createReadModelChangeJournal(changes).snapshot()).toEqual([
{ domain: 'city.content', entityId: 3 },
{ domain: 'contacts.world', entityId: 0 },
{ domain: 'front.general', entityId: 7 },
{ domain: 'front.global', entityId: 0 },
{ domain: 'front.nation', entityId: 2 },
{ domain: 'general.content', entityId: 7 },
{ domain: 'lobby.general', entityId: 7 },
{ domain: 'lobby.world', entityId: 0 },
{ domain: 'map.general', entityId: 8 },
{ domain: 'map.world', entityId: 0 },
{ domain: 'nation.content', entityId: 2 },
{ domain: 'records.general', entityId: 7 },
{ domain: 'records.global', entityId: 0 },
{ domain: 'records.history', entityId: 0 },
{ domain: 'reserved.general', entityId: 7 },
{ domain: 'world.content', entityId: 0 },
]);
});
it('ignores moving clock and lease metadata but detects month, turn-term, config, and gameplay meta', () => {
const state = {
currentYear: 190,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('2026-08-16T00:00:00.000Z'),
clockBaseTime: new Date('2026-08-16T00:00:00.000Z'),
clockTick: 1,
clockMode: 'realtime' as const,
clockWallAnchor: new Date('2026-08-16T00:00:00.000Z'),
lastTurnTick: 1,
meta: {
lastTurnTime: '2026-08-16T00:00:00.000Z',
heartbeatAt: '2026-08-16T00:00:00.000Z',
leaseUntil: '2026-08-16T00:01:00.000Z',
killturn: 24,
},
};
const config = { const: { feature: 'old' } };
const world = {
getState: () => ({ ...state, meta: { ...state.meta } }),
getScenarioConfig: () => config,
} as unknown as InMemoryTurnWorld;
const baseline = createWorldReadModelSignature(world);
state.lastTurnTime = new Date('2026-08-16T00:05:00.000Z');
state.clockTick = 2;
state.lastTurnTick = 2;
state.meta.lastTurnTime = '2026-08-16T00:05:00.000Z';
state.meta.heartbeatAt = '2026-08-16T00:00:10.000Z';
state.meta.leaseUntil = '2026-08-16T00:01:10.000Z';
expect(createWorldReadModelSignature(world)).toBe(baseline);
state.currentMonth = 2;
expect(createWorldReadModelSignature(world)).not.toBe(baseline);
state.currentMonth = 1;
state.tickSeconds = 300;
expect(createWorldReadModelSignature(world)).not.toBe(baseline);
state.tickSeconds = 600;
config.const.feature = 'new';
expect(createWorldReadModelSignature(world)).not.toBe(baseline);
config.const.feature = 'old';
state.meta.killturn = 25;
expect(createWorldReadModelSignature(world)).not.toBe(baseline);
});
});
describe('summarizeRealtimeReadModelChanges', () => {
it('classifies the committed log rows even when they bypass in-memory log drafts', () => {
expect(