diff --git a/app/game-api/src/messages/diplomaticResponse.ts b/app/game-api/src/messages/diplomaticResponse.ts index 84a47d2f..8c6517b0 100644 --- a/app/game-api/src/messages/diplomaticResponse.ts +++ b/app/game-api/src/messages/diplomaticResponse.ts @@ -124,7 +124,7 @@ const persistEffects = async ( await persistLogs(db, logs, year, month, at); }; -const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise => { +const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise => { const [map, cities, diplomacy] = await Promise.all([ loadMapDefinitionByName(mapName), db.city.findMany({ @@ -152,6 +152,7 @@ const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds data: { frontState: patch.frontState }, }); } + return patches.map((patch) => patch.id); }; const buildFailureLog = (generalId: number, reason: string, actionName: string, response: boolean): LogEntryDraft[] => { @@ -167,6 +168,9 @@ export interface DiplomaticMessageResponseResult { result: boolean; reason: string; affectedMailboxes: number[]; + affectedGeneralRecordIds: number[]; + affectedNationIds: number[]; + affectedCityIds: number[]; } export const respondToDiplomaticMessage = async (options: { @@ -201,7 +205,14 @@ export const respondToDiplomaticMessage = async (options: { world.currentMonth, now ); - return { result: false, reason, affectedMailboxes: [] }; + return { + result: false, + reason, + affectedMailboxes: [], + affectedGeneralRecordIds: [actor.id], + affectedNationIds: [], + affectedCityIds: [], + }; }; const actorNationId = actor.nationId; @@ -266,6 +277,9 @@ export const respondToDiplomaticMessage = async (options: { result: true, reason: 'success', affectedMailboxes: [MESSAGE_MAILBOX_NATIONAL_BASE + actorNationId], + affectedGeneralRecordIds: [actor.id, proposerGeneralId], + affectedNationIds: [], + affectedCityIds: [], }; } @@ -369,11 +383,12 @@ export const respondToDiplomaticMessage = async (options: { } ); await persistEffects(db, resolution.effects, world.currentYear, world.currentMonth, now); + let affectedCityIds: number[] = []; if (resolution.refreshFront) { const worldConfig = asRecord(world.config); const environment = asRecord(worldConfig.environment); const mapName = typeof environment.mapName === 'string' ? environment.mapName : 'che'; - await refreshFrontStates(db, mapName, [actorNationId, proposerNationId]); + affectedCityIds = await refreshFrontStates(db, mapName, [actorNationId, proposerNationId]); } const proposerMessageNationName = message.payload.src.nationName; @@ -415,5 +430,8 @@ export const respondToDiplomaticMessage = async (options: { MESSAGE_MAILBOX_NATIONAL_BASE + actorNationId, MESSAGE_MAILBOX_NATIONAL_BASE + proposerNationId, ], + affectedGeneralRecordIds: [actor.id, proposerGeneralId], + affectedNationIds: [actorNationId, proposerNationId], + affectedCityIds, }; }; diff --git a/app/game-api/src/realtime/outboxWorker.ts b/app/game-api/src/realtime/outboxWorker.ts index 69bb5ecd..5d937ef9 100644 --- a/app/game-api/src/realtime/outboxWorker.ts +++ b/app/game-api/src/realtime/outboxWorker.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import { readModelOutboxPayloadToChanges, + readModelOutboxPayloadToMessageMailboxes, type ReadModelDomain, } from '@sammo-ts/common'; import { @@ -11,13 +12,14 @@ import { type RedisConnector, } from '@sammo-ts/infra'; -import { publishRealtimeReadModelChanges } from './publisher.js'; +import { publishRealtimeMessageChanges, publishRealtimeReadModelChanges } from './publisher.js'; -// access.general is an authoritative DB-only source revision. Tournament and -// betting still have separate Redis-owned source revisions. None of the three -// should wake the legacy dashboard channel solely because its outbox row ran. +// Source-only/access keys and separately owned tournament/betting state must +// not wake the legacy dashboard channel solely because an outbox row ran. const NON_DASHBOARD_DOMAINS: ReadonlySet = new Set([ 'access.general', + 'dashboard.global', + 'messages.mailbox', 'tournament', 'betting', ]); @@ -83,11 +85,14 @@ export class ReadModelOutboxWorker implements ReadModelOutboxWakeup { const result = await dispatchReadModelOutboxBatch( this.db, async (payload) => { - if (payload.changes.every(([domain]) => NON_DASHBOARD_DOMAINS.has(domain))) { - return; + const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload); + if (mailboxes.length > 0) { + await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes); + } + if (payload.changes.some(([domain]) => !NON_DASHBOARD_DOMAINS.has(domain))) { + const changes = readModelOutboxPayloadToChanges(payload); + await publishRealtimeReadModelChanges(this.redis, this.profileName, changes); } - const changes = readModelOutboxPayloadToChanges(payload); - await publishRealtimeReadModelChanges(this.redis, this.profileName, changes); }, { owner: this.owner, diff --git a/app/game-api/src/realtime/publicEvent.ts b/app/game-api/src/realtime/publicEvent.ts index cf70bd69..3417ccfb 100644 --- a/app/game-api/src/realtime/publicEvent.ts +++ b/app/game-api/src/realtime/publicEvent.ts @@ -62,8 +62,9 @@ export const toPublicRealtimeEvent = ( const viewers = uniqueIdentities( identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }] ); - if (event.type === 'messageCreated') { - return viewers.some((identity) => isMailboxRelevant(event.mailbox, identity)) + if (event.type === 'messageCreated' || event.type === 'messagesChanged') { + const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes; + return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity))) ? { type: 'messagesInvalidated' } : null; } diff --git a/app/game-api/src/realtime/publisher.ts b/app/game-api/src/realtime/publisher.ts index 1bc008ca..4b94386a 100644 --- a/app/game-api/src/realtime/publisher.ts +++ b/app/game-api/src/realtime/publisher.ts @@ -30,3 +30,15 @@ export const publishRealtimeReadModelChanges = async ( }); return revision; }; + +export const publishRealtimeMessageChanges = async ( + redis: RedisConnector['client'], + profileName: string, + mailboxes: readonly number[] +): Promise => { + if (mailboxes.length === 0) return; + await publishRealtimeEvent(redis, profileName, { + type: 'messagesChanged', + mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right), + }); +}; diff --git a/app/game-api/src/router/betting/index.ts b/app/game-api/src/router/betting/index.ts index 76b2d5e7..6fd53046 100644 --- a/app/game-api/src/router/betting/index.ts +++ b/app/game-api/src/router/betting/index.ts @@ -228,6 +228,8 @@ export const bettingRouter = router({ amount: input.amount, }, }); + ctx.changeJournal?.mark('general.content', general.id); + ctx.changeJournal?.mark('betting'); return { result: true }; }), }); diff --git a/app/game-api/src/router/messages/index.ts b/app/game-api/src/router/messages/index.ts index 60d56f7c..aaf5556c 100644 --- a/app/game-api/src/router/messages/index.ts +++ b/app/game-api/src/router/messages/index.ts @@ -4,6 +4,7 @@ import { asRecord } from '@sammo-ts/common'; import type { UserSanctions } from '@sammo-ts/common/auth/gameToken'; import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions'; +import type { GameApiContext } from '../../context.js'; import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; import { MESSAGE_MAILBOX_NATIONAL_BASE, @@ -22,7 +23,6 @@ import { insertMessage, type MessageView, } from '../../messages/store.js'; -import { publishRealtimeEvent } from '../../realtime/publisher.js'; import { getOwnedGeneral } from '../shared/general.js'; import { resolveNationPermission } from '../nation/shared.js'; import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js'; @@ -72,6 +72,15 @@ const hasPenalty = (penalty: unknown, key: string): boolean => { return value === true || value === 1 || value === '1'; }; +const markMessageMailboxes = ( + ctx: Pick, + mailboxes: Iterable +): void => { + for (const mailbox of mailboxes) { + ctx.changeJournal?.mark('messages.mailbox', mailbox); + } +}; + export const messagesRouter = router({ getRecent: accessLimitAuthedInputProcedure( z.object({ @@ -298,6 +307,15 @@ export const messagesRouter = router({ ...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []), ]; await invalidateMessages(ctx.db, ids); + const receiverMailbox = + shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private' + ? message.payload.dest.generalId + : shouldDeleteReceiverCopy && + typeof receiverMessageId === 'number' && + message.msgType === 'national' + ? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId + : null; + markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]); return { ok: true, deletedIds: ids }; }), respond: authedProcedure @@ -316,21 +334,21 @@ export const messagesRouter = router({ messageId: input.messageId, response: input.response, }); - if (result.result) { - for (const mailbox of result.affectedMailboxes) { - try { - await publishRealtimeEvent(ctx.redis, ctx.profile.name, { - type: 'messageCreated', - at: new Date().toISOString(), - mailbox, - msgType: 'diplomacy', - messageId: input.messageId, - senderId: general.id, - }); - } catch { - // 실시간 알림 실패는 외교 응답 실패로 취급하지 않는다. - } - } + markMessageMailboxes(ctx, result.affectedMailboxes); + for (const generalId of result.affectedGeneralRecordIds) { + ctx.changeJournal?.mark('records.general', generalId); + } + for (const nationId of result.affectedNationIds) { + ctx.changeJournal?.mark('nation.content', nationId); + } + for (const cityId of result.affectedCityIds) { + ctx.changeJournal?.mark('city.content', cityId); + } + if (result.affectedCityIds.length > 0) { + ctx.changeJournal?.mark('map.world'); + } + if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) { + ctx.changeJournal?.mark('dashboard.global'); } return { result: result.result, reason: result.reason }; }), @@ -522,18 +540,13 @@ export const messagesRouter = router({ draft ); - try { - await publishRealtimeEvent(ctx.redis, ctx.profile.name, { - type: 'messageCreated', - at: now.toISOString(), - mailbox: receiverMailbox, - msgType, - messageId: result.receiverId, - senderId: general.id, - }); - } catch { - // 실시간 알림 실패는 메시지 전송 실패로 취급하지 않는다. - } + const senderMailbox = + result.senderId === undefined + ? null + : msgType === 'private' + ? general.id + : MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId; + markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])]); return { msgType, msgId: result.receiverId }; }), diff --git a/app/game-api/src/router/nation/endpoints/setNotice.ts b/app/game-api/src/router/nation/endpoints/setNotice.ts index 8592d82b..f12fb1dc 100644 --- a/app/game-api/src/router/nation/endpoints/setNotice.ts +++ b/app/game-api/src/router/nation/endpoints/setNotice.ts @@ -35,5 +35,6 @@ export const setNotice = authedProcedure }, nationMeta ); + ctx.changeJournal?.mark('front.nation', me.nationId); return { ok: true, msg }; }); diff --git a/app/game-api/src/router/nation/shared.ts b/app/game-api/src/router/nation/shared.ts index 6f05ffc3..acd171b1 100644 --- a/app/game-api/src/router/nation/shared.ts +++ b/app/game-api/src/router/nation/shared.ts @@ -429,7 +429,7 @@ export const assertNationEditable = ( }; export const updateNationMeta = async ( - ctx: Pick, + ctx: Pick, nationId: number, updates: Record, currentMeta: Record @@ -453,6 +453,8 @@ export const updateNationMeta = async ( } throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason }); } + ctx.changeJournal?.mark('nation.content', nationId); + ctx.changeJournal?.mark('dashboard.global'); return { ...currentMeta, ...updates, diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index 11b78cf6..aad0d1ea 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -346,6 +346,7 @@ export const turnsRouter = router({ const snapshot = await mutateReservedTurns(() => setGeneralTurn(ctx.db, input.generalId, input.turnIndex, input.action, args, input.expectedRevision) ); + ctx.changeJournal?.mark('reserved.general', input.generalId); return { ok: true, ...snapshot }; }), shiftGeneral: authedProcedure @@ -362,6 +363,7 @@ export const turnsRouter = router({ const snapshot = await mutateReservedTurns(() => shiftGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision) ); + ctx.changeJournal?.mark('reserved.general', input.generalId); return { ok: true, ...snapshot }; }), repeatGeneral: authedProcedure @@ -377,6 +379,7 @@ export const turnsRouter = router({ const snapshot = await mutateReservedTurns(() => repeatGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision) ); + ctx.changeJournal?.mark('reserved.general', input.generalId); return { ok: true, ...snapshot }; }), setGeneralBulk: authedProcedure @@ -403,6 +406,7 @@ export const turnsRouter = router({ const snapshot = await mutateReservedTurns(() => setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision) ); + ctx.changeJournal?.mark('reserved.general', input.generalId); return { ok: true, ...snapshot }; }), setNation: authedProcedure diff --git a/app/game-api/test/directMutationJournalInventory.test.ts b/app/game-api/test/directMutationJournalInventory.test.ts new file mode 100644 index 00000000..13081d78 --- /dev/null +++ b/app/game-api/test/directMutationJournalInventory.test.ts @@ -0,0 +1,148 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const classifications = { + durableJournal: [ + 'betting.bet', + 'messages.delete', + 'messages.respond', + 'messages.send', + 'nation.setBill', + 'nation.setBlockScout', + 'nation.setBlockWar', + 'nation.setNotice', + 'nation.setRate', + 'nation.setScoutMsg', + 'nation.setSecretLimit', + 'npc.setGeneralPriority', + 'npc.setNationPolicy', + 'npc.setNationPriority', + 'turns.repeatGeneral', + 'turns.setGeneral', + 'turns.setGeneralBulk', + 'turns.shiftGeneral', + 'vote.closePoll', + 'vote.createPoll', + 'vote.submitVote', + 'vote.updatePoll', + ], + separateAccessJournal: ['public.recordAccess'], + explicitNoRealtimeConsumer: [ + 'board.writeArticle', + 'board.writeComment', + 'diplomacy.destroyLetter', + 'diplomacy.respondLetter', + 'diplomacy.rollbackLetter', + 'diplomacy.sendLetter', + 'inherit.checkOwner', + 'join.getSelectionPool', + 'join.listPossessCandidates', + 'messages.readLatest', + 'turns.repeatNation', + 'turns.setNation', + 'turns.setNationBulk', + 'turns.shiftNation', + 'vote.addComment', + ], + engineOwned: [ + 'auction.bidBuyRice', + 'auction.bidSellRice', + 'auction.bidUnique', + 'auction.openBuyRice', + 'auction.openSellRice', + 'auction.openUnique', + 'general.adjustIcon', + 'general.buildNationCandidate', + 'general.dieOnPrestart', + 'general.dropItem', + 'general.ensureDieOnPrestartStatus', + 'general.instantRetreat', + 'general.setMySetting', + 'general.vacation', + 'inherit.openUniqueAuction', + 'join.createGeneral', + 'join.possessGeneral', + 'join.reselectPoolGeneral', + 'join.selectPoolGeneral', + 'nation.appoint', + 'nation.changePermission', + 'nation.kick', + 'troop.create', + 'troop.exit', + 'troop.join', + 'troop.kick', + 'troop.rename', + ], + mixedSaga: [ + 'inherit.buyHiddenBuff', + 'inherit.buyRandomUnique', + 'inherit.resetSpecialWar', + 'inherit.resetStat', + 'inherit.resetTurnTime', + 'inherit.setNextSpecialWar', + 'tournament.cancel', + 'tournament.join', + 'tournament.placeBet', + ], + redisProjection: [ + 'tournament.patchState', + 'tournament.seedParticipants', + 'tournament.setBettingEntries', + 'tournament.setMatches', + 'tournament.setParticipants', + 'tournament.setState', + ], + operational: ['turnDaemon.pause', 'turnDaemon.resume', 'turnDaemon.run'], + externalUpload: ['board.uploadImage'], + readOnlyMutationTransport: ['battle.simulate'], + sessionOnly: ['auth.exchangeGatewayToken'], +} as const; + +const routerRoot = fileURLToPath(new URL('../src/router/', import.meta.url)); + +const listTypeScriptFiles = (directory: string): string[] => + readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) return listTypeScriptFiles(target); + return entry.isFile() && entry.name.endsWith('.ts') ? [target] : []; + }); + +const extractMutationNames = (file: string): string[] => { + const source = readFileSync(file, 'utf8'); + const names: string[] = []; + for (const mutation of source.matchAll(/\.mutation\s*\(/gu)) { + const prefix = source.slice(0, mutation.index); + const propertyCandidates = [...prefix.matchAll(/^ {4,8}([A-Za-z][A-Za-z0-9]*):/gmu)]; + const exportedCandidates = [...prefix.matchAll(/^export const ([A-Za-z][A-Za-z0-9]*)\s*=/gmu)]; + const property = propertyCandidates.at(-1); + const exported = exportedCandidates.at(-1); + const propertyIndex = property?.index ?? -1; + const exportedIndex = exported?.index ?? -1; + const name = propertyIndex > exportedIndex ? property?.[1] : exported?.[1]; + if (!name) throw new Error(`Could not resolve mutation name in ${file}`); + names.push(name); + } + return names; +}; + +const routePrefix = (file: string): string => { + const relative = path.relative(routerRoot, file); + const [top] = relative.split(path.sep); + if (!top) throw new Error(`Could not resolve router prefix for ${file}`); + return top.endsWith('.ts') ? path.basename(top, '.ts') : top; +}; + +describe('game-api direct mutation journal inventory', () => { + it('requires every router mutation to retain an explicit ownership and realtime classification', () => { + const actual = listTypeScriptFiles(routerRoot) + .flatMap((file) => extractMutationNames(file).map((name) => `${routePrefix(file)}.${name}`)) + .sort(); + const classified = Object.values(classifications).flat().sort(); + + expect(new Set(classified).size).toBe(classified.length); + expect(classified).toHaveLength(86); + expect(actual).toEqual(classified); + }); +}); diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index af4e208f..5e46da41 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; +import { ChangeJournal } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { appRouter } from '../src/router.js'; import type { GameApiContext, GeneralRow } from '../src/context.js'; @@ -210,6 +211,21 @@ describe('messages router missing-flow compatibility', () => { expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national'])); }); + it('journals committed message mailbox copies instead of publishing before commit', async () => { + const changeJournal = new ChangeJournal(); + const queryRaw = vi.fn(async () => [{ id: 51 }]); + const { caller, redis } = buildContext({ $queryRaw: queryRaw }, { changeJournal }); + + await caller.messages.send({ + generalId: general.id, + mailbox: 9999, + text: '공개 메시지', + }); + + expect(changeJournal.snapshot()).toEqual([{ domain: 'messages.mailbox', entityId: 9999 }]); + expect(redis.publish).not.toHaveBeenCalled(); + }); + it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => { const ambassador = { ...general, @@ -458,7 +474,8 @@ describe('messages router missing-flow compatibility', () => { }, }, ]); - const { caller, updateMany } = buildContext({ $queryRaw: queryRaw }); + const changeJournal = new ChangeJournal(); + const { caller, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal }); const result = await caller.messages.delete({ generalId: general.id, messageId: 21 }); @@ -467,6 +484,10 @@ describe('messages router missing-flow compatibility', () => { where: { id: { in: [21, 22] } }, data: { validUntil: expect.any(Date) }, }); + expect(changeJournal.snapshot()).toEqual([ + { domain: 'messages.mailbox', entityId: 7 }, + { domain: 'messages.mailbox', entityId: 8 }, + ]); }); it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => { @@ -671,6 +692,7 @@ describe('messages router missing-flow compatibility', () => { const logCreateMany = vi.fn(async () => ({ count: 1 })); const messageUpdateMany = vi.fn(async () => ({ count: 1 })); const cityUpdate = vi.fn(async () => ({})); + const changeJournal = new ChangeJournal(); const { caller } = buildContext({ general: { findUnique: vi.fn(async ({ where }: { where: { id: number } }) => @@ -748,7 +770,7 @@ describe('messages router missing-flow compatibility', () => { logEntry: { createMany: logCreateMany }, message: { updateMany: messageUpdateMany }, $queryRaw: queryRaw, - }); + }, { changeJournal }); return { caller, actor, @@ -759,6 +781,7 @@ describe('messages router missing-flow compatibility', () => { logCreateMany, messageUpdateMany, cityUpdate, + changeJournal, }; }; @@ -841,6 +864,18 @@ describe('messages router missing-flow compatibility', () => { where: { id: 9 }, data: { frontState: 0 }, }); + expect(setup.changeJournal.snapshot()).toEqual([ + { domain: 'city.content', entityId: 1 }, + { domain: 'city.content', entityId: 9 }, + { domain: 'dashboard.global', entityId: 0 }, + { domain: 'map.world', entityId: 0 }, + { domain: 'messages.mailbox', entityId: 9001 }, + { domain: 'messages.mailbox', entityId: 9002 }, + { domain: 'nation.content', entityId: 1 }, + { domain: 'nation.content', entityId: 2 }, + { domain: 'records.general', entityId: 7 }, + { domain: 'records.general', entityId: 8 }, + ]); } }); diff --git a/app/game-api/test/nationHtmlRouter.test.ts b/app/game-api/test/nationHtmlRouter.test.ts index 07ea5530..7a1cfd9c 100644 --- a/app/game-api/test/nationHtmlRouter.test.ts +++ b/app/game-api/test/nationHtmlRouter.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; +import { ChangeJournal } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { RedisConnector } from '@sammo-ts/infra'; @@ -70,6 +71,7 @@ const auth: GameSessionTokenPayload = { }; const buildContext = () => { + const changeJournal = new ChangeJournal(); const requestCommand = vi.fn(async (command: unknown) => ({ type: 'setNationMeta', ok: true, @@ -95,6 +97,7 @@ const buildContext = () => { battleSim: {} as GameApiContext['battleSim'], profile: { id: 'che', scenario: 'default', name: 'che:default' }, auth, + changeJournal, uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, @@ -102,7 +105,7 @@ const buildContext = () => { flushStore: new InMemoryFlushStore(), gameTokenSecret: 'test-secret', }; - return { caller: appRouter.createCaller(context), requestCommand }; + return { caller: appRouter.createCaller(context), requestCommand, changeJournal }; }; describe('nation HTML API boundary', () => { @@ -135,6 +138,11 @@ describe('nation HTML API boundary', () => { }, expectedUpdatedAt: undefined, }); + expect(fixture.changeJournal.snapshot()).toEqual([ + { domain: 'dashboard.global', entityId: 0 }, + ...(procedure === 'setNotice' ? [{ domain: 'front.nation' as const, entityId: 1 }] : []), + { domain: 'nation.content', entityId: 1 }, + ]); }); it.each(['setNotice', 'setScoutMsg'] as const)( diff --git a/app/game-api/test/nationPersonnelRouter.test.ts b/app/game-api/test/nationPersonnelRouter.test.ts index 9994b717..b9319798 100644 --- a/app/game-api/test/nationPersonnelRouter.test.ts +++ b/app/game-api/test/nationPersonnelRouter.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; +import { ChangeJournal } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { RedisConnector } from '@sammo-ts/infra'; @@ -70,6 +71,7 @@ const createContext = ( requestCommand?: ReturnType; requestId?: string; transaction?: ReturnType; + changeJournal?: ChangeJournal; } = {} ): GameApiContext => { const requestCommand = options.requestCommand ?? vi.fn(); @@ -86,6 +88,7 @@ const createContext = ( battleSim: {} as GameApiContext['battleSim'], profile: { id: 'che', scenario: 'default', name: 'che:default' }, auth, + ...(options.changeJournal ? { changeJournal: options.changeJournal } : {}), ...(options.requestId ? { requestId: options.requestId } : {}), uploadDir: 'uploads', uploadPath: '/uploads', @@ -242,10 +245,11 @@ describe('nation personnel router', () => { })); const headCommand = makeCommand(); + const changeJournal = new ChangeJournal(); await expect( - appRouter.createCaller(createContext({ db: nationDb, requestCommand: headCommand })).nation.setRate({ - amount: 20, - }) + appRouter + .createCaller(createContext({ db: nationDb, requestCommand: headCommand, changeJournal })) + .nation.setRate({ amount: 20 }) ).resolves.toEqual({ ok: true }); expect(headCommand).toHaveBeenCalledWith({ type: 'setNationMeta', @@ -253,6 +257,10 @@ describe('nation personnel router', () => { updates: { rate: 20 }, expectedUpdatedAt: '2026-01-01T00:00:00.000Z', }); + expect(changeJournal.snapshot()).toEqual([ + { domain: 'dashboard.global', entityId: 0 }, + { domain: 'nation.content', entityId: 1 }, + ]); const ambassadorCommand = makeCommand(); const ambassador = { diff --git a/app/game-api/test/publicRealtimeEvent.test.ts b/app/game-api/test/publicRealtimeEvent.test.ts index 19474989..db0a00a4 100644 --- a/app/game-api/test/publicRealtimeEvent.test.ts +++ b/app/game-api/test/publicRealtimeEvent.test.ts @@ -126,6 +126,23 @@ describe('public realtime event privacy boundary', () => { expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull(); }); + it('redacts durable mailbox wake-ups to one viewer-safe boolean event', () => { + const event: RealtimeEvent = { + type: 'messagesChanged', + mailboxes: [7, MESSAGE_MAILBOX_NATIONAL_BASE + 8], + }; + + const publicEvent = toPublicRealtimeEvent(event, [viewer]); + expect(publicEvent).toEqual({ type: 'messagesInvalidated' }); + expect(JSON.stringify(publicEvent)).not.toMatch(/7|9008|mailbox|revision|time/u); + expect( + toPublicRealtimeEvent( + { type: 'messagesChanged', mailboxes: [MESSAGE_MAILBOX_NATIONAL_BASE + 8] }, + [viewer] + ) + ).toBeNull(); + }); + it('requests an identity refresh only when the viewer general may have changed', () => { expect( shouldReloadRealtimeViewerIdentity( diff --git a/app/game-api/test/readModelOutboxWorker.test.ts b/app/game-api/test/readModelOutboxWorker.test.ts index 8a140461..a38b22d7 100644 --- a/app/game-api/test/readModelOutboxWorker.test.ts +++ b/app/game-api/test/readModelOutboxWorker.test.ts @@ -5,9 +5,11 @@ import type { ReadModelOutboxDatabase } from '@sammo-ts/infra'; import { ReadModelOutboxWorker } from '../src/realtime/outboxWorker.js'; -const payload = (domain: 'front.general' | 'access.general' | 'tournament' | 'betting') => ({ +const payload = ( + domain: 'front.general' | 'access.general' | 'dashboard.global' | 'messages.mailbox' | 'tournament' | 'betting' +) => ({ version: 1, - changes: [[domain, domain === 'front.general' || domain === 'access.general' ? 7 : 0, '1']], + changes: [[domain, domain === 'front.general' || domain === 'access.general' ? 7 : domain === 'messages.mailbox' ? 9999 : 0, '1']], }); const createFixture = (rows: readonly object[]) => { @@ -47,7 +49,7 @@ describe('ReadModelOutboxWorker', () => { ); }); - it.each(['access.general', 'tournament', 'betting'] as const)( + it.each(['access.general', 'dashboard.global', 'tournament', 'betting'] as const)( 'marks a %s-only envelope delivered without dashboard Redis publish', async (domain) => { const fixture = createFixture([{ id: 12n, payload: payload(domain), attempts: 1 }]); @@ -65,6 +67,24 @@ describe('ReadModelOutboxWorker', () => { } ); + it('publishes a durable mailbox wake-up without the legacy dashboard revision', async () => { + const fixture = createFixture([{ id: 14n, payload: payload('messages.mailbox'), attempts: 1 }]); + const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', { + owner: 'worker-test', + intervalMs: 60_000, + }); + + worker.start(); + await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1)); + await worker.stop(); + + expect(fixture.incr).not.toHaveBeenCalled(); + expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toEqual({ + type: 'messagesChanged', + mailboxes: [9999], + }); + }); + it('coalesces repeated wakeups into one trailing batch and waits for it on shutdown', async () => { let releaseFirst: (() => void) | undefined; const first = new Promise((resolve) => { diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 32dd49be..7653d32f 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -15,6 +15,7 @@ import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js' import { InMemoryFlushStore } from '../src/auth/flushStore.js'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { appRouter } from '../src/router.js'; +import { ChangeJournal } from '@sammo-ts/common'; import { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; const profile: GameProfile = { @@ -118,6 +119,7 @@ const buildContext = (options?: { accountIconGet?: (userId: string) => Promise; accessTokenStore?: RedisAccessTokenStore; worldStateReads?: { count: number }; + changeJournal?: ChangeJournal; }): GameApiContext => { const transport = options?.transport ?? new InMemoryTurnDaemonTransport(); const battleSim = options?.battleSim ?? new InMemoryBattleSimTransport(); @@ -227,6 +229,7 @@ const buildContext = (options?: { battleSim, profile, auth, + ...(options?.changeJournal ? { changeJournal: options.changeJournal } : {}), uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, @@ -868,8 +871,9 @@ describe('appRouter', () => { it('validates and persists general command arguments from the authenticated owner', async () => { const general = buildGeneralRow({ id: 13 }); const writes: unknown[] = []; + const changeJournal = new ChangeJournal(); const caller = appRouter.createCaller( - buildContext({ state: buildWorldState(), general, generalTurnWrites: writes }) + buildContext({ state: buildWorldState(), general, generalTurnWrites: writes, changeJournal }) ); const response = await caller.turns.reserved.setGeneral({ @@ -890,6 +894,7 @@ describe('appRouter', () => { actionCode: 'che_화계', arg: { destCityId: 7 }, }); + expect(changeJournal.snapshot()).toEqual([{ domain: 'reserved.general', entityId: 13 }]); await expect( caller.turns.reserved.setGeneral({ diff --git a/docs/architecture/game-api-direct-mutation-journal-inventory.md b/docs/architecture/game-api-direct-mutation-journal-inventory.md new file mode 100644 index 00000000..8f961c67 --- /dev/null +++ b/docs/architecture/game-api-direct-mutation-journal-inventory.md @@ -0,0 +1,89 @@ +# game-api 직접 mutation / change journal inventory + +## 범위와 판정 규칙 + +`app/game-api/src/router/**`에서 `.mutation()`으로 선언한 86개 route를 2026-08-16 +기준으로 전수 분류한다. 이 목록은 “mutation transport를 사용한다”와 “game DB를 +변경한다”를 구분한다. 신규 route가 추가되면 +`app/game-api/test/directMutationJournalInventory.test.ts`가 실패하므로 소유권과 +실시간 소비자를 먼저 정해야 한다. + +분류 기준은 다음과 같다. + +- `durable journal`: API가 성공한 DB mutation의 dashboard/message dependency를 + request-local `ChangeJournal`에 표시하고 같은 API transaction에서 revision/outbox를 + 쓴다. +- `separate access journal`: gameplay input-event와 분리된 접속 계측 transaction이 + `access.general`을 직접 쓴다. public fan-out은 없다. +- `engine owned`: 실제 game mutation은 ENGINE `input_event` transaction이 소유한다. + API에서 같은 표식을 중복 생성하지 않는다. +- `mixed saga`: ENGINE DB 변경과 API DB/Redis 변경이 하나의 atomic transaction이 + 아니다. 현 상태를 atomic journal coverage로 오인하지 않는다. +- `explicit no realtime consumer`: 저장값은 바뀌지만 현재 SSE 자동 갱신 consumer가 + 없다. 존재하지 않는 browser fan-out을 만들지 않는다. +- `Redis projection`: 토너먼트의 authoritative state가 현재 Redis이고 PostgreSQL + journal과 원자적이지 않다. + +`coverageVersion`은 이 inventory가 존재한다는 이유로 올리지 않으며 계속 `0`이다. +특히 mixed saga, Redis tournament state와 ENGINE writer inventory의 reconciliation이 +끝나기 전 revision equality fast path를 활성화하지 않는다. + +## 전수 목록 + +| 분류 | 수 | route | +| --- | ---: | --- | +| durable journal | 22 | `betting.bet`; `messages.delete`, `messages.respond`, `messages.send`; `nation.setBill`, `nation.setBlockScout`, `nation.setBlockWar`, `nation.setNotice`, `nation.setRate`, `nation.setScoutMsg`, `nation.setSecretLimit`; `npc.setGeneralPriority`, `npc.setNationPolicy`, `npc.setNationPriority`; `turns.repeatGeneral`, `turns.setGeneral`, `turns.setGeneralBulk`, `turns.shiftGeneral`; `vote.closePoll`, `vote.createPoll`, `vote.submitVote`, `vote.updatePoll` | +| separate access journal | 1 | `public.recordAccess` | +| explicit no realtime consumer | 15 | `board.writeArticle`, `board.writeComment`; `diplomacy.destroyLetter`, `diplomacy.respondLetter`, `diplomacy.rollbackLetter`, `diplomacy.sendLetter`; `inherit.checkOwner`; `join.getSelectionPool`, `join.listPossessCandidates`; `messages.readLatest`; `turns.repeatNation`, `turns.setNation`, `turns.setNationBulk`, `turns.shiftNation`; `vote.addComment` | +| engine owned | 27 | `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique`, `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`; `general.adjustIcon`, `general.buildNationCandidate`, `general.dieOnPrestart`, `general.dropItem`, `general.ensureDieOnPrestartStatus`, `general.instantRetreat`, `general.setMySetting`, `general.vacation`; `inherit.openUniqueAuction`; `join.createGeneral`, `join.possessGeneral`, `join.reselectPoolGeneral`, `join.selectPoolGeneral`; `nation.appoint`, `nation.changePermission`, `nation.kick`; `troop.create`, `troop.exit`, `troop.join`, `troop.kick`, `troop.rename` | +| mixed saga | 9 | `inherit.buyHiddenBuff`, `inherit.buyRandomUnique`, `inherit.resetSpecialWar`, `inherit.resetStat`, `inherit.resetTurnTime`, `inherit.setNextSpecialWar`; `tournament.cancel`, `tournament.join`, `tournament.placeBet` | +| Redis projection | 6 | `tournament.patchState`, `tournament.seedParticipants`, `tournament.setBettingEntries`, `tournament.setMatches`, `tournament.setParticipants`, `tournament.setState` | +| operational | 3 | `turnDaemon.pause`, `turnDaemon.resume`, `turnDaemon.run` | +| external upload | 1 | `board.uploadImage` | +| read-only mutation transport | 1 | `battle.simulate` | +| session only | 1 | `auth.exchangeGatewayToken` | + +합계는 86개다. + +## durable journal dependency 매핑 + +| writer | durable key | public wake-up | 근거와 경계 | +| --- | --- | --- | --- | +| `betting.bet` | `general.content:`, `betting:0` | 없음 | 본인 베팅/유산 지출과 베팅 aggregate source가 바뀐다. `betting`은 현재 별도 화면 source이고 main dashboard fan-out을 만들지 않는다. | +| `messages.send` | 생성된 수신/송신 복사본의 `messages.mailbox:` | 해당 mailbox viewer에게 ID 없는 `messagesInvalidated` | 기존 pre-commit Redis `messageCreated`를 제거했다. outbox publish 뒤에도 browser에는 mailbox/message/sender/time/revision이 노출되지 않는다. | +| `messages.delete` | 실제로 만료한 송신/수신 mailbox | 동일 | sender copy만 지우는 수동 외교 메시지는 그 mailbox만 표시한다. | +| `messages.respond` | 영향 mailbox, `records.general`, 실제 외교 변경 국가의 `nation.content`, front-state patch 도시의 `city.content`, 필요 시 `map.world`, transitive aggregate용 `dashboard.global` | mailbox boolean 및 해당 dashboard slice | 실패 로그도 commit되면 actor 개인 기록을 표시한다. 외교 수락이 실제 diplomacy/city/nation dependency를 바꿀 때만 broad source key를 표시한다. | +| nation metadata 7개 및 NPC policy 3개 | `nation.content:`, `dashboard.global:0`; notice만 추가로 `front.nation:` | 해당 국가 context/command/board, notice front status | `updateNationMeta()` 성공 뒤 공통 표식을 사용한다. 다만 현재 mutation 자체는 ENGINE `setNationMeta`이고 API journal transaction과 단일 DB transaction이 아닌 기존 saga다. coverage 활성화 전에 ENGINE command 소유로 합쳐야 한다. | +| general reserved turn 4개 | `reserved.general:` | 본인 reserved-turn slice | queue row와 CAS revision을 쓴 같은 API transaction에서 표시한다. nation reserved turns는 main SSE consumer가 없어 명시적 no-op이다. | +| vote 4개 | 기존 `front.general`/`front.global` | front-status boolean | vote producer 작업에서 pre-commit publish를 journal로 이미 치환했다. 댓글은 active survey 제목을 바꾸지 않아 별도 화면 no-op이다. | +| `public.recordAccess` | `access.general:` | 없음 | Ref 순서상 gameplay transaction 밖의 별도 access transaction에 저장한다. | + +`dashboard.global`은 context/command source vector가 general/city/nation/troop/ +diplomacy aggregate에 간접 의존하는 점을 위한 DB/source-only key다. dispatcher는 이를 +public dashboard event로 내보내지 않는다. browser wake-up은 정밀 entity/domain key가 +담당하고, source equality 검사만 이 보수적 key를 사용한다. + +## 의도적으로 fan-out하지 않는 저장값 + +- 게시글/댓글은 현재 게시판 화면에서 사용자 action 뒤 직접 다시 읽으며 main SSE + listener가 없다. `board.*` domain을 임의로 추가하지 않는다. +- 외교 문서(`diplomacyLetter`)는 외교 문서 화면 전용이고 현재 SSE consumer가 없다. + 전쟁/불가침 상태를 실제 변경하는 `messages.respond`와 구분한다. +- `messages.readLatest`는 본인의 읽음 cursor다. 요청한 tab이 이미 최신 cursor를 알고 + 있으므로 자기 자신에게 다시 wake-up을 보내지 않는다. +- nation reserved turn, selection-pool reservation, possession 후보, inheritance owner + 확인은 각각 전용 화면/request response가 최신 상태를 소유한다. +- image upload는 외부 content store write이며 game PostgreSQL read model이 아니다. +- battle simulation은 호환상 mutation transport를 쓰지만 read-only 계산이다. + +## 남은 원자성/coverage gap + +1. nation metadata/NPC policy는 ENGINE DB commit 뒤 API input-event transaction이 + journal을 쓴다. API rollback과 ENGINE commit이 분리되는 기존 gap이 남는다. +2. inheritance와 tournament는 보상 가능한 saga지만 단일 transaction이 아니다. +3. Redis tournament state에는 PostgreSQL revision과 원자적인 Lua/MULTI revision이 + 아직 없다. +4. ENGINE에서 생성하는 message row도 `messages.mailbox`에 연결해야 전체 mailbox + producer coverage가 된다. +5. 위 gap과 초기 reconciliation이 끝나기 전 `read_model_revision_meta.coverage_version` + 은 반드시 `0`으로 유지한다. diff --git a/packages/common/src/realtime/changeJournal.ts b/packages/common/src/realtime/changeJournal.ts index 0adee42f..b9b0da47 100644 --- a/packages/common/src/realtime/changeJournal.ts +++ b/packages/common/src/realtime/changeJournal.ts @@ -2,6 +2,7 @@ export const READ_MODEL_DOMAINS = [ 'general.content', 'city.content', 'nation.content', + 'dashboard.global', 'world.content', 'map.world', 'map.general', @@ -16,6 +17,7 @@ export const READ_MODEL_DOMAINS = [ 'lobby.general', 'contacts.world', 'reserved.general', + 'messages.mailbox', 'tournament', 'betting', ] as const; diff --git a/packages/common/src/realtime/readModelOutbox.ts b/packages/common/src/realtime/readModelOutbox.ts index d8dd1184..7ff6d6ad 100644 --- a/packages/common/src/realtime/readModelOutbox.ts +++ b/packages/common/src/realtime/readModelOutbox.ts @@ -8,6 +8,13 @@ import { createEmptyRealtimeReadModelChanges, type RealtimeReadModelChanges } fr const uniqueSortedIds = (values: Iterable): number[] => [...new Set(values)].sort((left, right) => left - right); +export const readModelOutboxPayloadToMessageMailboxes = ( + payload: ReadModelOutboxPayloadV1 +): readonly number[] => + uniqueSortedIds( + payload.changes.flatMap(([domain, entityId]) => (domain === 'messages.mailbox' ? [entityId] : [])) + ); + export const parseReadModelOutboxPayload = (value: unknown): ReadModelOutboxPayloadV1 | null => { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null; @@ -73,6 +80,8 @@ export const readModelOutboxPayloadToChanges = ( case 'nation.content': nationIds.push(entityId); break; + case 'dashboard.global': + break; case 'world.content': changes.worldChanged = true; break; @@ -113,6 +122,7 @@ export const readModelOutboxPayloadToChanges = ( reservedGeneralIds.push(entityId); break; case 'access.general': + case 'messages.mailbox': case 'tournament': case 'betting': // These domains have no browser-wide dashboard invalidation. diff --git a/packages/common/src/realtime/types.ts b/packages/common/src/realtime/types.ts index 9ac59176..ade4f4f2 100644 --- a/packages/common/src/realtime/types.ts +++ b/packages/common/src/realtime/types.ts @@ -206,6 +206,12 @@ export interface MessageCreatedEvent { senderId: number; } +/** Durable mailbox wake-up derived from committed read-model outbox rows. */ +export interface MessagesChangedEvent { + type: 'messagesChanged'; + mailboxes: number[]; +} + export interface ReadModelInvalidatedEvent { type: 'readModelInvalidated'; invalidation: RealtimeReadModelInvalidation; @@ -218,4 +224,4 @@ export interface MessagesInvalidatedEvent { /** Events safe to expose to an authenticated browser over SSE. */ export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent; -export type RealtimeEvent = TurnCompletedEvent | ReadModelChangedEvent | MessageCreatedEvent; +export type RealtimeEvent = TurnCompletedEvent | ReadModelChangedEvent | MessageCreatedEvent | MessagesChangedEvent; diff --git a/packages/common/test/changeJournal.test.ts b/packages/common/test/changeJournal.test.ts index 2629a598..3a7592e8 100644 --- a/packages/common/test/changeJournal.test.ts +++ b/packages/common/test/changeJournal.test.ts @@ -11,7 +11,14 @@ import { describe('ChangeJournal', () => { it('keeps viewer-filtering semantics as distinct durable domains', () => { expect(READ_MODEL_DOMAINS).toEqual( - expect.arrayContaining(['map.general', 'lobby.general', 'contacts.world', 'reserved.general']) + expect.arrayContaining([ + 'map.general', + 'lobby.general', + 'contacts.world', + 'reserved.general', + 'messages.mailbox', + 'dashboard.global', + ]) ); }); diff --git a/packages/common/test/readModelOutbox.test.ts b/packages/common/test/readModelOutbox.test.ts index 2859687d..3603e1c4 100644 --- a/packages/common/test/readModelOutbox.test.ts +++ b/packages/common/test/readModelOutbox.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { hasRealtimeReadModelChanges, parseReadModelOutboxPayload, + readModelOutboxPayloadToMessageMailboxes, readModelOutboxPayloadToChanges, resolveRealtimeReadModelInvalidation, } from '../src/index.js'; @@ -71,11 +72,12 @@ describe('read-model outbox payload', () => { ); }); - it('keeps access-only, tournament, and betting domains off the dashboard channel', () => { + it('keeps source-only, access-only, tournament, and betting domains off the dashboard channel', () => { const payload = parseReadModelOutboxPayload({ version: 1, changes: [ ['access.general', 7, '1'], + ['dashboard.global', 0, '1'], ['tournament', 0, '1'], ['betting', 0, '1'], ], @@ -83,4 +85,19 @@ describe('read-model outbox payload', () => { if (!payload) throw new Error('valid payload rejected'); expect(hasRealtimeReadModelChanges(readModelOutboxPayloadToChanges(payload))).toBe(false); }); + + it('keeps durable mailbox revisions separate from dashboard invalidations', () => { + const payload = parseReadModelOutboxPayload({ + version: 1, + changes: [ + ['messages.mailbox', 9999, '3'], + ['messages.mailbox', 7, '2'], + ['messages.mailbox', 7, '2'], + ], + }); + if (!payload) throw new Error('valid payload rejected'); + + expect(readModelOutboxPayloadToMessageMailboxes(payload)).toEqual([7, 9999]); + expect(hasRealtimeReadModelChanges(readModelOutboxPayloadToChanges(payload))).toBe(false); + }); });