From 97b7c490706c5168dd489afd52ef71f7d71d77f3 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 19:48:38 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=99=B8=EA=B5=90=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=EC=9D=98=20=EC=8B=9C=EA=B3=84=20fallback=EA=B3=BC=20f?= =?UTF-8?q?ixture=20=EA=B2=BD=EA=B3=84=EB=A5=BC=20=EB=B3=B5=EC=9B=90?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/messages/store.ts | 37 ++++++------ ...lomacyDocumentMessages.integration.test.ts | 60 ++++++++++++++++++- ...diplomacyHtmlTransport.integration.test.ts | 33 +++++++++- 3 files changed, 108 insertions(+), 22 deletions(-) diff --git a/app/game-api/src/messages/store.ts b/app/game-api/src/messages/store.ts index c93f83ef..67ff197a 100644 --- a/app/game-api/src/messages/store.ts +++ b/app/game-api/src/messages/store.ts @@ -1,9 +1,9 @@ -import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra'; import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common'; +import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra'; import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic'; import type { DatabaseClient } from '../context.js'; -import { loadCurrentGameTime } from '../services/gameClock.js'; +import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js'; export interface MessageView { id: number; @@ -48,6 +48,19 @@ const formatMessageTime = (value: Date): string => { )} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`; }; +const messageValidityPredicate = (gameTime: CurrentGameTime) => { + if (gameTime.tick === null) { + // A legacy or partially migrated profile has no authoritative logical + // tick. Rows that already carry a tick still need the wall-time + // fallback used by the clock migration. + return GamePrisma.sql`valid_until > ${gameTime.now}`; + } + return GamePrisma.sql`( + (valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(gameTime.tick)}) + OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now}) + )`; +}; + const toMessageView = (row: MessageRow): MessageView => { const payload = parsePayload(row.message); return { @@ -113,10 +126,7 @@ export const fetchMessagesFromMailbox = async (params: { FROM message WHERE mailbox = ${params.mailbox} AND type = ${params.msgType} - AND ( - (valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)}) - OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now}) - ) + AND ${messageValidityPredicate(gameTime)} AND id >= ${fromSeq} ORDER BY id DESC LIMIT ${params.limit} @@ -138,10 +148,7 @@ export const fetchOldMessagesFromMailbox = async (params: { FROM message WHERE mailbox = ${params.mailbox} AND type = ${params.msgType} - AND ( - (valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)}) - OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now}) - ) + AND ${messageValidityPredicate(gameTime)} AND id < ${params.toSeq} ORDER BY id DESC LIMIT ${params.limit} @@ -156,10 +163,7 @@ export const fetchMessageById = async (db: DatabaseClient, id: number): Promise< SELECT id, mailbox, type, src, dest, time, valid_until, message FROM message WHERE id = ${id} - AND ( - (valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)}) - OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now}) - ) + AND ${messageValidityPredicate(gameTime)} LIMIT 1 `; const row = rows[0]; @@ -179,10 +183,7 @@ export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): SELECT id, mailbox, type, src, dest, time, valid_until, message FROM message WHERE id = ${id} - AND ( - (valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)}) - OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now}) - ) + AND ${messageValidityPredicate(gameTime)} LIMIT 1 FOR UPDATE `; diff --git a/app/game-api/test/diplomacyDocumentMessages.integration.test.ts b/app/game-api/test/diplomacyDocumentMessages.integration.test.ts index fcfd10ba..af301628 100644 --- a/app/game-api/test/diplomacyDocumentMessages.integration.test.ts +++ b/app/game-api/test/diplomacyDocumentMessages.integration.test.ts @@ -1,6 +1,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { JosaUtil } from '@sammo-ts/common'; +import { JosaUtil, MAX_SAFE_GAME_TICK } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra'; import { @@ -15,6 +15,7 @@ import { InMemoryFlushStore } from '../src/auth/flushStore.js'; import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; import type { GameApiContext } from '../src/context.js'; import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { fetchMessagesFromMailbox } from '../src/messages/store.js'; import { appRouter } from '../src/router.js'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; @@ -251,14 +252,22 @@ integration('diplomacy document message persistence', () => { const buildRollbackDatabase = (failure: Error): GameApiContext['db'] => { const database = db as unknown as GameApiContext['db']; + let failNextTransaction = true; return new Proxy(database, { get(target, property) { if (property === '$transaction') { - return async (callback: (transaction: GameApiContext['db']) => Promise) => - db.$transaction(async (transaction) => { + return async (callback: (transaction: GameApiContext['db']) => Promise) => { + if (!failNextTransaction) { + return db.$transaction((transaction) => + callback(transaction as unknown as GameApiContext['db']) + ); + } + failNextTransaction = false; + return db.$transaction(async (transaction) => { await callback(transaction as unknown as GameApiContext['db']); throw failure; }); + }; } return Reflect.get(target, property, target); }, @@ -370,6 +379,51 @@ integration('diplomacy document message persistence', () => { await expectInputEvent(chainedRequestId, 'sendLetter', fixtureUserId); }); + it('keeps permanent messages readable while a profile has no logical clock', async () => { + const created = await appRouter + .createCaller(buildContext('legacy-clock-fallback', fixtureAuth)) + .diplomacy.sendLetter({ + destNationId: foreignNationId, + brief: '시계 이관 중 외교문서', + detail: '시계 이관 중에도 보여야 합니다.', + }); + const receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + foreignNationId; + const receiver = await db.message.findFirstOrThrow({ + where: { mailbox: receiverMailbox, type: 'diplomacy' }, + orderBy: { id: 'desc' }, + }); + expect(receiver.validUntilTick).toBe(BigInt(MAX_SAFE_GAME_TICK)); + + await db.worldState.update({ + where: { id: fixtureWorldStateId }, + data: { clockBaseTime: null, clockTick: null, clockWallAnchor: null }, + }); + try { + const messages = await fetchMessagesFromMailbox({ + db, + mailbox: receiverMailbox, + msgType: 'diplomacy', + limit: 15, + fromSeq: 0, + }); + expect(messages).toContainEqual( + expect.objectContaining({ + id: receiver.id, + text: expect.stringContaining(`#${created.id}`), + }) + ); + } finally { + await db.worldState.update({ + where: { id: fixtureWorldStateId }, + data: { + clockBaseTime, + clockTick: logicalGameTick, + clockWallAnchor: new Date('2026-08-24T00:00:00.000Z'), + }, + }); + } + }); + it('stores diplomacy and national copies for both approval and rejection responses', async () => { const approved = await createLetter(); const approveRequestId = 'respond-approve'; diff --git a/app/game-api/test/diplomacyHtmlTransport.integration.test.ts b/app/game-api/test/diplomacyHtmlTransport.integration.test.ts index 64cccf11..13bf2196 100644 --- a/app/game-api/test/diplomacyHtmlTransport.integration.test.ts +++ b/app/game-api/test/diplomacyHtmlTransport.integration.test.ts @@ -12,6 +12,7 @@ import { type GamePrismaClient, type RedisConnector, } from '@sammo-ts/infra'; +import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { createGameApiServer } from '../src/server.js'; @@ -21,8 +22,14 @@ const integration = describe.skipIf(!databaseUrl || !process.env.REDIS_URL); const profileId = process.env.POSTGRES_SCHEMA ?? 'public'; const profileName = `che:diplomacy-html-${process.pid}`; const userId = `diplomacy-html-user-${process.pid}`; -const fixtureId = 920_000 + (process.pid % 50_000); +// National and diplomacy mailboxes use the Ref-compatible 9000 + nation id +// address space, so a real nation fixture must stay in 1..998; 999 is public. +const fixtureId = 861; const foreignNationId = fixtureId + 1; +const fixtureMailboxes = [ + MESSAGE_MAILBOX_NATIONAL_BASE + fixtureId, + MESSAGE_MAILBOX_NATIONAL_BASE + foreignNationId, +] as const; const secret = 'diplomacy-html-http-secret'; const redisPrefix = `sammo:diplomacy-html:${process.pid}`; const envKeys = [ @@ -66,7 +73,22 @@ const deleteProfileRedisKeys = async (): Promise => { } }; +const isFixtureOutboxPayload = (payload: unknown): boolean => { + if (!payload || typeof payload !== 'object' || !('changes' in payload)) return false; + const changes = (payload as { changes?: unknown }).changes; + return ( + Array.isArray(changes) && + changes.some( + (change) => + Array.isArray(change) && + change[0] === 'messages.mailbox' && + fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number]) + ) + ); +}; + const cleanup = async (): Promise => { + await db.message.deleteMany({ where: { mailbox: { in: [...fixtureMailboxes] } } }); await db.diplomacyLetter.deleteMany({ where: { OR: [ @@ -77,6 +99,15 @@ const cleanup = async (): Promise => { ], }, }); + await db.inputEvent.deleteMany({ where: { actorUserId: userId } }); + await db.readModelRevision.deleteMany({ + where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } }, + }); + const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } }); + const outboxIds = outboxes.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id); + if (outboxIds.length > 0) { + await db.readModelOutbox.deleteMany({ where: { id: { in: outboxIds } } }); + } await db.generalAccessLog.deleteMany({ where: { generalId: fixtureId } }); await db.general.deleteMany({ where: { id: fixtureId } }); await db.nation.deleteMany({ where: { id: { in: [fixtureId, foreignNationId] } } });