기존 외교 문서를 플레이 감사 최초 관측으로 보존

This commit is contained in:
2026-09-16 06:53:16 +00:00
parent c719cecc80
commit ae0dfd503f
9 changed files with 314 additions and 39 deletions
@@ -0,0 +1,123 @@
import { asRecord } from '@sammo-ts/common';
import {
hashAuditDiplomacyDocument,
persistAuditDiplomacyEvents,
projectAuditDocumentState,
GamePrisma,
} from '@sammo-ts/infra';
import type { InMemoryTurnWorld } from '../turn/inMemoryWorld.js';
export const hasAuditDocumentBaseline = (world: InMemoryTurnWorld): boolean => {
const state = world.getState();
const { meta } = state;
const serverId = meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return true;
const marker = asRecord(meta.playAuditDocuments);
if (marker.serverId !== serverId) return false;
if (
marker.schemaVersion !== 1 ||
typeof marker.documentCount !== 'number' ||
!Number.isSafeInteger(marker.documentCount) ||
marker.documentCount < 0 ||
typeof marker.year !== 'number' ||
!Number.isSafeInteger(marker.year) ||
marker.year < 0 ||
typeof marker.month !== 'number' ||
!Number.isInteger(marker.month) ||
marker.month < 1 ||
marker.month > 12 ||
marker.year * 12 + marker.month > state.currentYear * 12 + state.currentMonth ||
typeof marker.tick !== 'number' ||
!Number.isSafeInteger(marker.tick) ||
marker.tick < 0 ||
typeof marker.clockRevision !== 'number' ||
!Number.isSafeInteger(marker.clockRevision) ||
marker.clockRevision < 0 ||
typeof marker.observedAt !== 'string' ||
!Number.isFinite(Date.parse(marker.observedAt))
)
throw new Error('Invalid play audit document boundary');
return true;
};
/** CLOCK lock을 잡은 startup transaction 안에서만 호출한다. 과거 문서 사건을 복원하지 않는다. */
export const persistAuditDocumentBaseline = async (
db: GamePrisma.TransactionClient,
world: InMemoryTurnWorld,
observedAt: Date
): Promise<void> => {
if (hasAuditDocumentBaseline(world)) return;
const state = world.getState();
const serverId = state.meta.serverId;
if (typeof serverId !== 'string' || !serverId.trim()) return;
const observedAtIso = observedAt.toISOString();
const [identity] = await db.$queryRaw<Array<{ serverId: string | null }>>(GamePrisma.sql`
SELECT meta->>'serverId' AS "serverId" FROM world_state WHERE id = ${state.id} FOR UPDATE
`);
if (identity?.serverId !== serverId) throw new Error('Play audit document baseline season changed');
const clock = world.getGameClockState();
let cursor = 0;
let documentCount = 0;
while (true) {
// 문서 본문은 불변 참조의 해시에만 필요하다. 메모리에는 한 batch만 유지한다.
const letters = await db.diplomacyLetter.findMany({
where: { id: { gt: cursor } },
orderBy: { id: 'asc' },
take: 200,
select: {
id: true,
srcNationId: true,
destNationId: true,
prevId: true,
textBrief: true,
textDetail: true,
srcSignerId: true,
destSignerId: true,
state: true,
aux: true,
date: true,
},
});
await persistAuditDiplomacyEvents(
db,
letters.map((letter) => ({
schemaVersion: 1,
serverId,
srcNationId: letter.srcNationId,
destNationId: letter.destNationId,
category: 'DOCUMENT',
source: 'BASELINE',
eventType: 'LETTER_BASELINE',
documentId: letter.id,
documentHash: hashAuditDiplomacyDocument(letter),
previousDocumentId: letter.prevId,
year: state.currentYear,
month: state.currentMonth,
tick: BigInt(clock.tick),
clockRevision: BigInt(clock.revision),
executionId: 'document-baseline',
ordinal: letter.id,
requestId: null,
inputSequence: null,
actor: null,
before: null,
after: projectAuditDocumentState(letter),
}))
);
documentCount += letters.length;
if (letters.length < 200) break;
cursor = letters[letters.length - 1]!.id;
}
world.updateWorldMeta({
playAuditDocuments: {
schemaVersion: 1,
serverId,
year: state.currentYear,
month: state.currentMonth,
tick: clock.tick,
clockRevision: clock.revision,
observedAt: observedAtIso,
documentCount,
},
});
};
+28
View File
@@ -1,4 +1,5 @@
import { persistAuditDiplomacyEvents } from '@sammo-ts/infra';
import { hasAuditDocumentBaseline, persistAuditDocumentBaseline } from '../playAudit/documentBaseline.js';
import { persistAuditPolicies } from '../playAudit/policyPersistence.js';
import { prunePreviousAuditBatch, type AuditRetentionResult } from '../playAudit/retention.js';
import { persistAuditMonth } from '../playAudit/persistence.js';
@@ -76,6 +77,7 @@ import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
flushChanges(): Promise<void>;
flushInitialAudit(observedAt: Date, force?: boolean): Promise<void>;
takeCommittedReadModelChanges(): RealtimeReadModelChanges | null;
takeCommittedReadModelChangeReceipt(): CommittedReadModelChangeReceipt | null;
close(): Promise<void>;
@@ -2087,6 +2089,31 @@ export const createDatabaseTurnHooks = async (
committed.acknowledge();
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
};
const flushInitialAudit = async (observedAt: Date, force = false): Promise<void> => {
if (hasAuditDocumentBaseline(world)) {
if (force || world.hasPendingAuditRecords()) await flushChanges();
return;
}
const checkpoint = world.captureState();
let committed: Awaited<ReturnType<typeof persistChanges>>;
try {
committed = await prisma.$transaction(async (transaction) => {
// seed/RESET과 같은 schema lock을 먼저 잡아 문서 scan 중 초기화를 막는다.
await transaction.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(current_schema(), 0))::text AS lock_result`;
await options?.turnDaemonLease?.assertActive(transaction);
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
await acquireGameSchemaAdvisoryXactLock(transaction, GENERAL_ACCESS_PERSISTENCE_LOCK);
await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
await persistAuditDocumentBaseline(transaction, world, observedAt);
return persistChanges(transaction);
}, transactionOptions);
} catch (error) {
world.restoreState(checkpoint);
throw error;
}
committed.acknowledge();
enqueueCommittedReceipt(committed.readModelChanges, committed.journalWrite);
};
const hooks: TurnDaemonHooks = {
flushChanges,
commitCommand: async (requestId, result) => {
@@ -2133,6 +2160,7 @@ export const createDatabaseTurnHooks = async (
return {
hooks,
flushChanges,
flushInitialAudit,
takeCommittedReadModelChanges: () => {
return takeCommittedReceipt()?.changes ?? null;
},
+2 -4
View File
@@ -933,10 +933,8 @@ const createTurnDaemonRuntimeWithLease = async (
initializeAuditPolicies(world);
const diplomacyInitialized = initializeAuditDiplomacy(world, new Date(clock.nowMs()));
initializeAuditCollection(world, new Date(clock.nowMs()));
if (world.hasPendingAuditRecords() || diplomacyInitialized) {
await dbHooks.flushChanges();
dbHooks.takeCommittedReadModelChangeReceipt();
}
await dbHooks.flushInitialAudit(new Date(clock.nowMs()), diplomacyInitialized);
dbHooks.takeCommittedReadModelChangeReceipt();
} catch (error) {
await Promise.allSettled([
dbHooks.close(),
@@ -1,6 +1,11 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { asRecord, GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import {
createGamePostgresConnector,
hashAuditDiplomacyDocument,
type GamePrismaClient,
type GamePrisma,
} from '@sammo-ts/infra';
import { seedScenarioToDatabase } from '../src/scenario/scenarioSeeder.js';
import { createTurnDaemonRuntime, type TurnDaemonRuntime } from '../src/turn/turnDaemon.js';
@@ -66,6 +71,21 @@ integration('initial audit durability before runtime readiness', () => {
await db.diplomacy.create({
data: { srcNationId: 91990, destNationId: 91991, stateCode: 7, term: 12, meta: { dead: 34 } },
});
await db.diplomacyLetter.createMany({
data: Array.from({ length: 201 }, (_, index) => ({
id: 1000 + index,
srcNationId: 91990,
destNationId: 91991,
prevId: index ? 999 + index : null,
state: index === 200 ? ('ACTIVATED' as const) : ('REPLACED' as const),
textBrief: `도입 전 문서 ${index}`,
textDetail: `<p>보유 원문 ${index}</p>`,
date: new Date('2026-09-01T00:00:00Z'),
srcSignerId: 70001,
destSignerId: 70002,
aux: { src: { nationName: '옛 국명', generalName: '옛 서명자' }, debug: '비공개 임의 값' },
})),
});
}, 60_000);
afterAll(async () => {
await runtime?.close();
@@ -92,6 +112,7 @@ integration('initial audit durability before runtime readiness', () => {
expect(await db.playAuditPolicy.count()).toBe(0);
expect(await db.playAuditDiplomacyEvent.count()).toBe(0);
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy).toBeUndefined();
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toBeUndefined();
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection).toBeUndefined();
expect(
(await db.nation.findMany()).every((nation) => asRecord(nation.meta)._playAuditPolicy === undefined)
@@ -117,7 +138,7 @@ integration('initial audit durability before runtime readiness', () => {
const initial = await db.playAuditMonth.findFirstOrThrow({ where: { serverId, kind: 'INITIAL' } });
const policies = await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } });
const diplomacy = await db.playAuditDiplomacyEvent.findMany({
where: { serverId },
where: { serverId, category: 'RELATION' },
orderBy: { ordinal: 'asc' },
});
expect(diplomacy).toHaveLength(
@@ -146,6 +167,26 @@ integration('initial audit durability before runtime readiness', () => {
).toBe(true);
const diplomacyMarker = asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy;
expect(diplomacyMarker).toMatchObject({ serverId, schemaVersion: 1, relationCount: diplomacy.length });
const documentEvents = await db.playAuditDiplomacyEvent.findMany({
where: { serverId, category: 'DOCUMENT' },
orderBy: { ordinal: 'asc' },
});
expect(documentEvents).toHaveLength(201);
const currentLetter = await db.diplomacyLetter.findUniqueOrThrow({ where: { id: 1200 } });
expect(documentEvents[200]).toMatchObject({
source: 'BASELINE',
eventType: 'LETTER_BASELINE',
documentId: 1200,
previousDocumentId: 1199,
documentHash: hashAuditDiplomacyDocument(currentLetter),
actor: null,
before: null,
after: { state: 'ACTIVATED', srcNationName: '옛 국명', srcSignerName: '옛 서명자' },
});
expect(documentEvents[0]).toMatchObject({ after: { state: 'REPLACED' } });
expect(JSON.stringify(documentEvents.map(({ after }) => after))).not.toContain('비공개 임의 값');
const documentMarker = asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments;
expect(documentMarker).toMatchObject({ serverId, schemaVersion: 1, documentCount: 201 });
expect(policies).toHaveLength((await db.nation.count()) * 4);
expect(
@@ -171,10 +212,20 @@ integration('initial audit durability before runtime readiness', () => {
expect(await db.playAuditPolicy.findMany({ where: { serverId }, orderBy: { id: 'asc' } })).toEqual(policies);
expect(await db.playAuditMonth.findMany({ where: { serverId, kind: 'INITIAL' } })).toEqual([initial]);
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditCollection).toEqual(marker);
expect(await db.playAuditDiplomacyEvent.findMany({ where: { serverId }, orderBy: { ordinal: 'asc' } })).toEqual(
diplomacy
);
expect(
await db.playAuditDiplomacyEvent.findMany({
where: { serverId, category: 'RELATION' },
orderBy: { ordinal: 'asc' },
})
).toEqual(diplomacy);
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDiplomacy).toEqual(diplomacyMarker);
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toEqual(documentMarker);
expect(
await db.playAuditDiplomacyEvent.findMany({
where: { serverId, category: 'DOCUMENT' },
orderBy: { ordinal: 'asc' },
})
).toEqual(documentEvents);
expect(await clock()).toEqual(beforeClock);
expect(await db.inputEvent.count()).toBe(beforeInputs);
await expect(
@@ -208,4 +259,24 @@ integration('initial audit durability before runtime readiness', () => {
expect(policies.every((policy) => policy.tick === runtime!.world.getGameClockState().tick)).toBe(true);
expect(await db.playAuditMonth.findMany({ where: { serverId, kind: 'INITIAL' } })).toEqual([initial]);
}, 30_000);
it('adopts an empty document collection without duplicating an existing initial sample', async () => {
await runtime?.close();
runtime = undefined;
const original = await db.worldState.findFirstOrThrow();
const meta = asRecord(original.meta);
delete meta.playAuditDocuments;
await db.diplomacyLetter.deleteMany();
await db.playAuditDiplomacyEvent.deleteMany({ where: { category: 'DOCUMENT' } });
await db.worldState.update({ where: { id: original.id }, data: { meta: meta as GamePrisma.InputJsonObject } });
const samples = await db.playAuditMonth.findMany({ orderBy: { id: 'asc' } });
runtime = await start();
const marker = asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments;
expect(marker).toMatchObject({ serverId, schemaVersion: 1, documentCount: 0 });
expect(await db.playAuditDiplomacyEvent.count({ where: { category: 'DOCUMENT' } })).toBe(0);
expect(await db.playAuditMonth.findMany({ orderBy: { id: 'asc' } })).toEqual(samples);
await runtime.close();
runtime = undefined;
runtime = await start();
expect(asRecord((await db.worldState.findFirstOrThrow()).meta).playAuditDocuments).toEqual(marker);
}, 30_000);
});