feat: dashboard source coverage를 안전하게 활성화

엔진의 부대·예약턴·집계 변화와 mailbox writer를 저널에 연결하고 dashboard.global 및 인증 아이콘 source를 dependency vector에 포함한다. post-deploy coverage v1 활성화는 shared head seed와 CAS를 한 transaction으로 수행한다.
This commit is contained in:
2026-08-16 18:51:17 +00:00
parent 346f796cfc
commit 1a77bb3df1
16 changed files with 404 additions and 22 deletions
+33 -1
View File
@@ -463,6 +463,31 @@ export const createReadModelChangeJournal = (changes: RealtimeReadModelChanges):
return journal;
};
/**
* Conservative transitive dependency for the private dashboard projections.
* Context reads troop/leader-turn state and command options scan the complete
* general/city/nation sets, so entity-local heads alone are insufficient.
*/
export const hasDashboardSourceMutation = (
changes: TurnWorldChanges,
readModelChanges: RealtimeReadModelChanges
): boolean =>
readModelChanges.worldChanged ||
readModelChanges.reservedGeneralIds.length > 0 ||
changes.generals.length > 0 ||
changes.createdGenerals.length > 0 ||
changes.deletedGenerals.length > 0 ||
changes.lifecycleEvents.length > 0 ||
changes.cities.length > 0 ||
changes.nations.length > 0 ||
changes.createdNations.length > 0 ||
changes.deletedNations.length > 0 ||
changes.troops.length > 0 ||
changes.createdTroops.length > 0 ||
changes.deletedTroops.length > 0 ||
changes.diplomacy.length > 0 ||
changes.createdDiplomacy.length > 0;
export const excludeDeletedReservedTurnQueues = (
changes: ReservedTurnChanges,
deletedGeneralIds: readonly number[],
@@ -1463,13 +1488,15 @@ export const createDatabaseTurnHooks = async (
for (const snapshot of pendingYearbookSnapshots) {
await persistYearbookSnapshot(prisma, snapshot);
}
const persistedMessageMailboxes: number[] = [];
for (const finalization of pendingUnificationFinalizations) {
if (options?.profileName && finalization.profileName !== options.profileName) {
throw new Error(
`Unification profile mismatch: pending=${finalization.profileName}, daemon=${options.profileName}.`
);
}
await persistUnificationFinalization(prisma, finalization, world);
const result = await persistUnificationFinalization(prisma, finalization, world);
persistedMessageMailboxes.push(...result.messageMailboxes);
}
for (const message of messages) {
await sendMessage(
@@ -1506,6 +1533,7 @@ export const createDatabaseTurnHooks = async (
if (!id) {
throw new Error('Failed to persist turn message.');
}
persistedMessageMailboxes.push(draft.mailbox);
return id;
},
},
@@ -1555,6 +1583,10 @@ export const createDatabaseTurnHooks = async (
readModelChanges.worldChanged = true;
}
const journal = createReadModelChangeJournal(readModelChanges);
if (hasDashboardSourceMutation(changes, readModelChanges)) {
journal.mark('dashboard.global');
}
markIds(journal, 'messages.mailbox', uniqueSortedIds(persistedMessageMailboxes));
markIds(journal, 'access.general', accessScoreResetGeneralIds);
if (pendingNationBettingOpens.length > 0 || pendingNationBettingFinishes.length > 0) {
journal.mark('betting');
@@ -24,6 +24,7 @@ export interface UnificationFinalizationInput {
export interface UnificationFinalizationResult {
status: 'APPLIED' | 'ALREADY_APPLIED';
generationKey: string;
messageMailboxes: number[];
}
const readNumber = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
@@ -140,7 +141,8 @@ const cancelPendingUniqueAuctions = async (
transaction: GamePrisma.TransactionClient,
input: UnificationFinalizationInput,
world: InMemoryTurnWorld
): Promise<void> => {
): Promise<number[]> => {
const messageMailboxes: number[] = [];
const lockedRows = await transaction.$queryRaw<LockedUnificationAuctionRow[]>`
SELECT
auction.id AS "auctionId",
@@ -245,9 +247,17 @@ const cancelPendingUniqueAuctions = async (
time: input.completedAt,
validUntil: new Date('9999-12-31T00:00:00.000Z'),
};
await sendMessage({ insertMessage: (draft) => insertMessage(transaction, draft) }, message, {
sendDestOnly: true,
});
await sendMessage(
{
insertMessage: async (draft) => {
const messageId = await insertMessage(transaction, draft);
messageMailboxes.push(draft.mailbox);
return messageId;
},
},
message,
{ sendDestOnly: true }
);
}
await transaction.auction.update({
@@ -255,6 +265,7 @@ const cancelPendingUniqueAuctions = async (
data: { status: 'CANCELED', finishedAt: input.completedAt },
});
}
return messageMailboxes;
};
export const persistUnificationFinalization = async (
@@ -267,7 +278,7 @@ export const persistUnificationFinalization = async (
}
const claim = await claimGeneration(transaction, input);
if (claim === 'ALREADY_APPLIED') {
return { status: 'ALREADY_APPLIED', generationKey: input.generationKey };
return { status: 'ALREADY_APPLIED', generationKey: input.generationKey, messageMailboxes: [] };
}
const state = world.getState();
@@ -298,7 +309,7 @@ export const persistUnificationFinalization = async (
// Ref cancels and refunds every unfinished unique auction before it merges
// inheritance. Keep that order inside the generation transaction.
await cancelPendingUniqueAuctions(transaction, input, world);
const messageMailboxes = await cancelPendingUniqueAuctions(transaction, input, world);
const pointRows = eligibleGenerals.length
? await transaction.inheritancePoint.findMany({
@@ -655,5 +666,5 @@ export const persistUnificationFinalization = async (
},
});
return { status: 'APPLIED', generationKey: input.generationKey };
return { status: 'APPLIED', generationKey: input.generationKey, messageMailboxes };
};
@@ -165,6 +165,7 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
globalRecordsChanged: true,
});
expect(stateAndLogReceipt?.invalidation.revisions).toEqual([
{ domain: 'dashboard.global', entityId: 0, revision: 1n },
{ domain: 'map.world', entityId: 0, revision: 1n },
{ domain: 'records.global', entityId: 0, revision: 1n },
{ domain: 'world.content', entityId: 0, revision: 1n },
@@ -177,6 +178,7 @@ integration('game-engine read-model journal PostgreSQL transaction', () => {
const monthReceipt = hooks.takeCommittedReadModelChangeReceipt();
expect(monthReceipt?.changes.worldChanged).toBe(true);
expect(monthReceipt?.invalidation.revisions).toEqual([
{ domain: 'dashboard.global', entityId: 0, revision: 2n },
{ domain: 'map.world', entityId: 0, revision: 2n },
{ domain: 'world.content', entityId: 0, revision: 2n },
]);
@@ -7,6 +7,7 @@ import {
createReadModelChangeJournal,
createRealtimeReadModelBaseline,
createWorldReadModelSignature,
hasDashboardSourceMutation,
mergePersistedVisibleLogChanges,
summarizeRealtimeReadModelChanges,
} from '../src/turn/databaseHooks.js';
@@ -59,6 +60,51 @@ describe('durable read-model change journal mapping', () => {
]);
});
it('detects troop, leader-turn, and aggregate dashboard dependencies conservatively', () => {
const emptyWorldChanges = {
accessScoreResetGeneralIds: [],
generals: [],
cities: [],
nations: [],
troops: [],
deletedTroops: [],
deletedGenerals: [],
deletedNations: [],
deletedNationSnapshots: [],
diplomacy: [],
logs: [],
messages: [],
createdGenerals: [],
createdNations: [],
createdTroops: [],
createdDiplomacy: [],
createdEvents: [],
deletedEvents: [],
lifecycleEvents: [],
pendingNeutralAuctions: [],
inheritancePointAdjustments: [],
pendingNationBettingOpens: [],
pendingNationBettingFinishes: [],
pendingYearbookSnapshots: [],
pendingUnificationFinalizations: [],
} satisfies TurnWorldChanges;
const readModelChanges = createEmptyRealtimeReadModelChanges();
expect(hasDashboardSourceMutation(emptyWorldChanges, readModelChanges)).toBe(false);
expect(
hasDashboardSourceMutation(
{ ...emptyWorldChanges, troops: [{ id: 3 } as TurnWorldChanges['troops'][number]] },
readModelChanges
)
).toBe(true);
expect(
hasDashboardSourceMutation(emptyWorldChanges, {
...readModelChanges,
reservedGeneralIds: [7],
})
).toBe(true);
});
it('ignores moving clock and lease metadata but detects month, turn-term, config, and gameplay meta', () => {
const state = {
currentYear: 190,
@@ -175,6 +175,7 @@ describe('persistUnificationFinalization', () => {
await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({
status: 'ALREADY_APPLIED',
generationKey: input.generationKey,
messageMailboxes: [],
});
expect(transaction.unificationFinalization.create).not.toHaveBeenCalled();
});
@@ -221,6 +222,7 @@ describe('persistUnificationFinalization', () => {
await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({
status: 'APPLIED',
generationKey: input.generationKey,
messageMailboxes: [],
});
expect(inheritanceUpsert).toHaveBeenCalledWith(