diff --git a/app/game-api/src/router/vote/index.ts b/app/game-api/src/router/vote/index.ts index 0eb358af..ca92a4cf 100644 --- a/app/game-api/src/router/vote/index.ts +++ b/app/game-api/src/router/vote/index.ts @@ -394,10 +394,27 @@ export const voteRouter = router({ ? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } }) : null; const nationName = nation?.name ?? '재야'; + const createdAt = new Date(); await ctx.db.$queryRaw(GamePrisma.sql` - INSERT INTO vote_comment (vote_id, general_id, nation_id, general_name, nation_name, text) - VALUES (${input.voteId}, ${general.id}, ${general.nationId}, ${general.name}, ${nationName}, ${input.text}) + INSERT INTO vote_comment ( + vote_id, + general_id, + nation_id, + general_name, + nation_name, + text, + created_at + ) + VALUES ( + ${input.voteId}, + ${general.id}, + ${general.nationId}, + ${general.name}, + ${nationName}, + ${input.text}, + ${createdAt} + ) `); return { ok: true }; @@ -430,6 +447,7 @@ export const voteRouter = router({ if (endAt && endAt < gameTime.now) { throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); } + const operationalAt = new Date(); let multipleOptions = input.multipleOptions; if (multipleOptions < 0) { @@ -442,7 +460,7 @@ export const voteRouter = router({ if (input.closePrevious) { await ctx.db.$queryRaw(GamePrisma.sql` UPDATE vote_poll - SET closed_at = ${gameTime.now}, updated_at = NOW() + SET closed_at = ${gameTime.now}, updated_at = ${operationalAt} WHERE closed_at IS NULL `); } @@ -459,7 +477,9 @@ export const voteRouter = router({ start_at, start_tick, end_at, - end_tick + end_tick, + created_at, + updated_at ) VALUES ( ${input.title}, @@ -472,7 +492,9 @@ export const voteRouter = router({ ${gameTime.now}, ${gameTime.tick === null ? null : BigInt(gameTime.tick)}, ${endAt}, - ${toGameTickOrNull(gameTime, endAt)} + ${toGameTickOrNull(gameTime, endAt)}, + ${operationalAt}, + ${operationalAt} ) `); @@ -547,6 +569,7 @@ export const voteRouter = router({ if (endAt && endAt < gameTime.now) { throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); } + const updatedAt = new Date(); if ( input.title === undefined && @@ -569,7 +592,7 @@ export const voteRouter = router({ reveal_mode = COALESCE(${input.revealMode}, reveal_mode), end_at = ${endAt ?? poll.end_at}, end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick}, - updated_at = NOW() + updated_at = ${updatedAt} WHERE id = ${input.voteId} `); @@ -581,9 +604,11 @@ export const voteRouter = router({ closePoll: adminProcedure .input(z.object({ voteId: z.number().int().positive() })) .mutation(async ({ ctx, input }) => { + const gameTime = await loadCurrentGameTime(ctx.db); + const updatedAt = new Date(); const rows = await ctx.db.$queryRaw>(GamePrisma.sql` UPDATE vote_poll - SET closed_at = ${(await loadCurrentGameTime(ctx.db)).now}, updated_at = NOW() + SET closed_at = ${gameTime.now}, updated_at = ${updatedAt} WHERE id = ${input.voteId} RETURNING id `); diff --git a/app/game-api/test/inputEventBoundary.integration.test.ts b/app/game-api/test/inputEventBoundary.integration.test.ts index 55e07850..02112b59 100644 --- a/app/game-api/test/inputEventBoundary.integration.test.ts +++ b/app/game-api/test/inputEventBoundary.integration.test.ts @@ -99,6 +99,8 @@ integration('API input event boundary', () => { actorUserId: 'user-7', attempts: 1, }); + expect(event.processingAt).toBeInstanceOf(Date); + expect(Math.abs(event.createdAt.getTime() - (event.processingAt?.getTime() ?? 0))).toBeLessThan(1_000); expect(marker.status).toBe('PENDING'); }); @@ -254,7 +256,12 @@ integration('API input event boundary', () => { it('reuses the same engine child event but rejects a changed retry payload', async () => { const transport = new DatabaseTurnDaemonTransport(db, 100); const requestId = 'integration:api:engine-child'; + const acceptedWindowStart = Date.now(); await transport.sendCommand({ type: 'vacation', requestId, generalId: 7 }); + const acceptedWindowEnd = Date.now(); + const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } }); + expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart); + expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd); await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 7 })).resolves.toBe(requestId); await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 8 })).rejects.toBeInstanceOf( ConflictingTurnDaemonCommandError diff --git a/app/game-api/test/voteCommentTimestamp.integration.test.ts b/app/game-api/test/voteCommentTimestamp.integration.test.ts new file mode 100644 index 00000000..055c9c50 --- /dev/null +++ b/app/game-api/test/voteCommentTimestamp.integration.test.ts @@ -0,0 +1,177 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import type { GameApiContext } from '../src/context.js'; +import { appRouter } from '../src/router.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const fixtureId = 9_984_241; +const fallbackPollId = fixtureId + 1; +const fixtureUserId = 'vote-comment-timestamp-user'; +const routeText = '설문 댓글 UTC writer 검증'; +const fallbackText = '설문 댓글 UTC default 검증'; + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:vote-comment-timestamp', + issuedAt: '2026-08-24T00:00:00.000Z', + expiresAt: '2027-08-24T00:00:00.000Z', + sessionId: 'vote-comment-timestamp-session', + user: { + id: fixtureUserId, + username: fixtureUserId, + displayName: fixtureUserId, + roles: [], + }, + sanctions: {}, +}; + +integration('vote comment operational timestamp', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + const cleanup = async (): Promise => { + await db.inputEvent.deleteMany({ + where: { requestId: { startsWith: 'integration:vote-comment-timestamp' } }, + }); + await db.voteComment.deleteMany({ where: { voteId: { in: [fixtureId, fallbackPollId] } } }); + await db.vote.deleteMany({ where: { voteId: { in: [fixtureId, fallbackPollId] } } }); + await db.votePoll.deleteMany({ where: { id: { in: [fixtureId, fallbackPollId] } } }); + await db.general.deleteMany({ where: { id: fixtureId } }); + await db.nation.deleteMany({ where: { id: fixtureId } }); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await cleanup(); + await db.nation.create({ data: { id: fixtureId, name: '시각국', color: '#123456' } }); + await db.general.create({ + data: { + id: fixtureId, + userId: fixtureUserId, + name: '시각장수', + nationId: fixtureId, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + }, + }); + await db.votePoll.create({ + data: { + id: fixtureId, + title: '시각 설문', + options: ['찬성', '반대'], + multipleOptions: 1, + revealMode: 'after_vote', + openerGeneralId: fixtureId, + openerName: '시각장수', + startAt: new Date('0200-01-01T00:00:00.000Z'), + }, + }); + }); + + afterAll(async () => { + await cleanup(); + await closeDb?.(); + }); + + it('stores current writers and rollback-compatible vote defaults as UTC wall time in KST', async () => { + const [session] = await db.$queryRaw>` + SELECT current_setting('TIMEZONE') AS "timeZone" + `; + expect(session?.timeZone).toBe('Asia/Seoul'); + + const routeWindowStart = Date.now(); + const caller = appRouter.createCaller({ + requestId: 'integration:vote-comment-timestamp', + db, + auth, + profile: { id: 'che', scenario: 'vote-comment-timestamp', name: 'che:vote-comment-timestamp' }, + turnDaemon: {}, + } as unknown as GameApiContext); + await expect(caller.vote.addComment({ voteId: fixtureId, text: routeText })).resolves.toEqual({ ok: true }); + const routeWindowEnd = Date.now(); + + const fallbackWindowStart = Date.now(); + await db.$executeRaw` + INSERT INTO "vote_comment" ( + "vote_id", + "general_id", + "nation_id", + "general_name", + "nation_name", + "text" + ) + VALUES ( + ${fixtureId}, + ${fixtureId}, + ${fixtureId}, + '시각장수', + '시각국', + ${fallbackText} + ) + `; + const fallbackWindowEnd = Date.now(); + + const pollFallbackWindowStart = Date.now(); + await db.$executeRaw` + INSERT INTO "vote_poll" ( + "id", + "title", + "body", + "options", + "multiple_options", + "reveal_mode", + "opener_general_id", + "opener_name", + "start_at" + ) + VALUES ( + ${fallbackPollId}, + '이전 writer 기본값 설문', + '', + '["찬성", "반대"]'::jsonb, + 1, + 'after_vote', + ${fixtureId}, + '시각장수', + ${new Date('0200-01-01T00:00:00.000Z')} + ) + `; + const pollFallbackWindowEnd = Date.now(); + + const voteFallbackWindowStart = Date.now(); + await db.$executeRaw` + INSERT INTO "vote" ("vote_id", "general_id", "nation_id", "selection") + VALUES (${fixtureId}, ${fixtureId}, ${fixtureId}, '[0]'::jsonb) + `; + const voteFallbackWindowEnd = Date.now(); + + const rows = await db.voteComment.findMany({ + where: { voteId: fixtureId }, + select: { text: true, createdAt: true }, + }); + const routeRow = rows.find((row) => row.text === routeText); + const fallbackRow = rows.find((row) => row.text === fallbackText); + expect(routeRow?.createdAt.getTime()).toBeGreaterThanOrEqual(routeWindowStart); + expect(routeRow?.createdAt.getTime()).toBeLessThanOrEqual(routeWindowEnd); + expect(fallbackRow?.createdAt.getTime()).toBeGreaterThanOrEqual(fallbackWindowStart); + expect(fallbackRow?.createdAt.getTime()).toBeLessThanOrEqual(fallbackWindowEnd); + + const fallbackPoll = await db.votePoll.findUniqueOrThrow({ where: { id: fallbackPollId } }); + expect(fallbackPoll.createdAt.getTime()).toBeGreaterThanOrEqual(pollFallbackWindowStart); + expect(fallbackPoll.createdAt.getTime()).toBeLessThanOrEqual(pollFallbackWindowEnd); + expect(fallbackPoll.updatedAt.getTime()).toBeGreaterThanOrEqual(pollFallbackWindowStart); + expect(fallbackPoll.updatedAt.getTime()).toBeLessThanOrEqual(pollFallbackWindowEnd); + + const fallbackVote = await db.vote.findUniqueOrThrow({ + where: { voteId_generalId: { voteId: fixtureId, generalId: fixtureId } }, + }); + expect(fallbackVote.createdAt.getTime()).toBeGreaterThanOrEqual(voteFallbackWindowStart); + expect(fallbackVote.createdAt.getTime()).toBeLessThanOrEqual(voteFallbackWindowEnd); + }); +}); diff --git a/app/game-api/test/voteRouter.test.ts b/app/game-api/test/voteRouter.test.ts index a55ce09c..dbb75419 100644 --- a/app/game-api/test/voteRouter.test.ts +++ b/app/game-api/test/voteRouter.test.ts @@ -130,6 +130,9 @@ const buildContext = (options: { if (text.includes('INSERT INTO vote_comment')) { return []; } + if (text.includes('UPDATE vote_poll')) { + return [{ id: 1 }]; + } return []; }); const db = { @@ -289,6 +292,60 @@ describe('vote router actor and permission boundaries', () => { expect(insert?.values).not.toContain('관리자 장수'); }); + it('binds current operational timestamps in every raw SQL vote writer', async () => { + const auth = buildAuth(['admin.survey.open']); + const fixture = buildContext({ auth }); + const caller = appRouter.createCaller(fixture.context); + const windowStart = Date.now(); + + await expect(caller.vote.addComment({ voteId: 1, text: '시각 댓글' })).resolves.toEqual({ ok: true }); + await expect( + caller.vote.createPoll({ + title: '시각 설문', + options: ['찬성', '반대'], + revealMode: 'after_vote', + closePrevious: true, + }) + ).resolves.toEqual({ ok: true }); + await expect(caller.vote.updatePoll({ voteId: 1, title: '시각 설문 수정' })).resolves.toEqual({ ok: true }); + await expect(caller.vote.closePoll({ voteId: 1 })).resolves.toEqual({ ok: true }); + const windowEnd = Date.now(); + + const mutationQueries = fixture.queryRaw.mock.calls + .map(([query]) => query) + .filter((query) => /INSERT INTO vote_comment|INSERT INTO vote_poll|UPDATE vote_poll/.test(sqlText(query))); + const commentInsert = mutationQueries.find((query) => sqlText(query).includes('INSERT INTO vote_comment')); + const pollInsert = mutationQueries.find((query) => sqlText(query).includes('INSERT INTO vote_poll')); + const pollUpdates = mutationQueries.filter((query) => sqlText(query).includes('UPDATE vote_poll')); + const closePreviousUpdate = pollUpdates.find((query) => sqlText(query).includes('WHERE closed_at IS NULL')); + const editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE')); + const closePollUpdate = pollUpdates.find((query) => sqlText(query).includes('RETURNING id')); + const expectCurrentDateAt = (query: GamePrisma.Sql | undefined, index: number): Date => { + expect(query).toBeDefined(); + const value = query?.values.at(index); + expect(value).toBeInstanceOf(Date); + expect((value as Date).getTime()).toBeGreaterThanOrEqual(windowStart); + expect((value as Date).getTime()).toBeLessThanOrEqual(windowEnd); + return value as Date; + }; + + expect(sqlText(commentInsert!)).toContain('created_at'); + expectCurrentDateAt(commentInsert, -1); + expect(sqlText(pollInsert!)).toContain('created_at'); + expect(sqlText(pollInsert!)).toContain('updated_at'); + const pollCreatedAt = expectCurrentDateAt(pollInsert, -2); + const pollUpdatedAt = expectCurrentDateAt(pollInsert, -1); + expect(pollUpdatedAt).toBe(pollCreatedAt); + + expect(pollUpdates).toHaveLength(3); + expect(sqlText(closePreviousUpdate!)).toContain('updated_at'); + expect(expectCurrentDateAt(closePreviousUpdate, -1)).toBe(pollCreatedAt); + expect(sqlText(editPollUpdate!)).toContain('updated_at'); + expectCurrentDateAt(editPollUpdate, -2); + expect(sqlText(closePollUpdate!)).toContain('updated_at'); + expectCurrentDateAt(closePollUpdate, -2); + }); + it('reports the current world develcost as the legacy five-times survey reward', async () => { const fixture = buildContext({ metaDevelCost: 30, configConst: { develCost: 0 } }); diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index e1e69a84..e015373c 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -2479,12 +2479,14 @@ const insertVoteSelection = async ( if (!ctx.commandDb) { return 'missing'; } + const createdAt = new Date(); const rows = await ctx.commandDb.$queryRaw>(GamePrisma.sql` - INSERT INTO vote (vote_id, general_id, nation_id, selection) + INSERT INTO vote (vote_id, general_id, nation_id, selection, created_at) SELECT poll.id, ${general.id}, ${general.nationId}, - CAST(${JSON.stringify(selection)} AS jsonb) + CAST(${JSON.stringify(selection)} AS jsonb), + ${createdAt} FROM vote_poll poll WHERE poll.id = ${command.voteId} ON CONFLICT (vote_id, general_id) DO NOTHING diff --git a/app/game-engine/test/gameCancellation.integration.test.ts b/app/game-engine/test/gameCancellation.integration.test.ts index fd770e18..8dbc5c4b 100644 --- a/app/game-engine/test/gameCancellation.integration.test.ts +++ b/app/game-engine/test/gameCancellation.integration.test.ts @@ -286,4 +286,37 @@ integration('game cancellation transaction', () => { generalMode: 'DELETE', }); }); + + it('keeps a native inheritance log inside the cancellation boundary under a KST session', async () => { + const text = '신규/복귀 생성으로 포인트 1500 지급'; + await db.inheritanceLog.deleteMany({ where: { userId, text } }); + const [session] = await db.$queryRaw>` + SELECT current_setting('TIMEZONE') AS "timeZone" + `; + expect(session?.timeZone).toBe('Asia/Seoul'); + + const createdAfter = Date.now(); + const log = await db.inheritanceLog.create({ + data: { userId, serverId, year: 190, month: 7, text }, + }); + const createdBefore = Date.now(); + expect(log.createdAt.getTime()).toBeGreaterThanOrEqual(createdAfter); + expect(log.createdAt.getTime()).toBeLessThanOrEqual(createdBefore); + + const result = await cancelGame({ + cancellationId: 'game-cancellation-kst-default-fixture', + databaseUrl: databaseUrl!, + cancelledBy: 'admin', + reason: 'KST 기본값 경계 검증', + historyMode: 'RETAIN_ABANDONED', + generalMode: 'RETAIN', + earnedPointRetentionPercent: 40, + cancelledAt: new Date(createdBefore + 1_000), + }); + expect(result.settlements[userId]).toMatchObject({ + earnedPoint: 1_790.005, + retainedEarnedPoint: 716, + finalPoint: 10_716, + }); + }); }); diff --git a/app/game-engine/test/voteReward.test.ts b/app/game-engine/test/voteReward.test.ts index 5583dd4f..ec8be31c 100644 --- a/app/game-engine/test/voteReward.test.ts +++ b/app/game-engine/test/voteReward.test.ts @@ -222,11 +222,12 @@ describe('voteReward command', () => { let voteInserted = false; let voteQueryCount = 0; + let voteInsertQuery: { strings: readonly string[]; values: readonly unknown[] } | undefined; const commandDb = { auction: { findMany: async () => [], }, - $queryRaw: async (query: { strings: readonly string[] }) => { + $queryRaw: async (query: { strings: readonly string[]; values: readonly unknown[] }) => { voteQueryCount += 1; if (query.strings.join(' ').includes('SELECT options')) { return [ @@ -239,6 +240,7 @@ describe('voteReward command', () => { }, ]; } + voteInsertQuery = query; if (voteInserted) return []; voteInserted = true; return [{ id: 11 }]; @@ -255,12 +257,23 @@ describe('voteReward command', () => { acceptedGameTick: 0, }; + const writerWindowStart = Date.now(); const result = await handler.handle(command, { db: commandDb as any }); + const writerWindowEnd = Date.now(); expect(result && result.type).toBe('voteReward'); if (!result || result.type !== 'voteReward' || !result.ok) { throw new Error('voteReward result missing'); } expect(result.awardedUnique).toBe(true); + expect(voteInsertQuery?.strings.join(' ')).toContain('created_at'); + expect( + voteInsertQuery?.values.filter( + (value) => + value instanceof Date && + value.getTime() >= writerWindowStart && + value.getTime() <= writerWindowEnd + ) + ).toHaveLength(1); const updated = world.getGeneralById(1); // ENGINE is the single reward linearization point, so it uses the diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index b541f0af..b758423d 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -684,6 +684,197 @@ export const buildWorkspaceCommands = ( return commands; }; +const PROFILE_MIGRATION_TIME_ZONE = 'Asia/Seoul'; +const PROFILE_MIGRATION_TIME_ZONE_OPTION = `-c TimeZone=${PROFILE_MIGRATION_TIME_ZONE}`; +const PROFILE_MIGRATION_TIME_ZONE_MENTION = /(^|[^A-Z0-9_])timezone(?=$|[^A-Z0-9_])/iu; + +const profileMigrationTimeZoneError = (source: string): Error => + new Error( + `Profile migration refused: ${source} must not configure a TimeZone other than ${PROFILE_MIGRATION_TIME_ZONE}.` + ); + +const tokenizePostgresOptions = (rawOptions: string, source: string): string[] => { + const tokens: string[] = []; + let token = ''; + let quote: "'" | '"' | null = null; + let escaping = false; + + for (const character of rawOptions) { + if (escaping) { + token += character; + escaping = false; + continue; + } + if (character === '\\') { + escaping = true; + continue; + } + if (quote) { + if (character === quote) quote = null; + else token += character; + continue; + } + if (character === "'" || character === '"') { + quote = character; + continue; + } + if (/\s/u.test(character)) { + if (token) { + tokens.push(token); + token = ''; + } + continue; + } + token += character; + } + + if (escaping || quote) throw profileMigrationTimeZoneError(source); + if (token) tokens.push(token); + return tokens; +}; + +const readPostgresOptionTimeZones = (rawOptions: string, source: string): string[] => { + const tokens = tokenizePostgresOptions(rawOptions, source); + const timeZones: string[] = []; + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]!; + let setting: string | undefined; + if (token === '-c') { + setting = tokens[index + 1]; + index += 1; + } else if (token.startsWith('-c') && token.length > 2) { + setting = token.slice(2); + } else if (token.startsWith('--') && token.length > 2) { + setting = token.slice(2); + } + + if (!setting) { + if (PROFILE_MIGRATION_TIME_ZONE_MENTION.test(token)) throw profileMigrationTimeZoneError(source); + continue; + } + + const separator = setting.indexOf('='); + const name = separator >= 0 ? setting.slice(0, separator).trim() : setting.trim(); + if (name.toLowerCase() !== 'timezone') continue; + const value = separator >= 0 ? setting.slice(separator + 1).trim() : ''; + if (!value) throw profileMigrationTimeZoneError(source); + timeZones.push(value); + } + + if (timeZones.length === 0 && PROFILE_MIGRATION_TIME_ZONE_MENTION.test(rawOptions)) { + throw profileMigrationTimeZoneError(source); + } + return timeZones; +}; + +const assertProfileMigrationTimeZone = (timeZone: string, source: string): void => { + if (timeZone.trim().toLowerCase() !== PROFILE_MIGRATION_TIME_ZONE.toLowerCase()) { + throw profileMigrationTimeZoneError(source); + } +}; + +const inspectProfileMigrationDatabaseUrl = ( + profileDatabaseUrl: string +): { url: URL; optionKeys: string[]; existingOptions: string[]; configuredTimeZones: string[] } => { + let url: URL; + try { + url = new URL(profileDatabaseUrl); + } catch { + throw new Error('Profile migration refused: DATABASE_URL is not a valid URL.'); + } + + const optionKeys = [...new Set([...url.searchParams.keys()].filter((key) => key.toLowerCase() === 'options'))]; + const existingOptions = optionKeys + .flatMap((key) => url.searchParams.getAll(key)) + .map((value) => value.trim()) + .filter(Boolean); + const configuredTimeZones = existingOptions.flatMap((options) => + readPostgresOptionTimeZones(options, 'DATABASE_URL options') + ); + for (const timeZone of configuredTimeZones) { + assertProfileMigrationTimeZone(timeZone, 'DATABASE_URL options'); + } + for (const [key, value] of url.searchParams) { + if (key.toLowerCase() === 'timezone') assertProfileMigrationTimeZone(value, 'DATABASE_URL'); + } + + return { url, optionKeys, existingOptions, configuredTimeZones }; +}; + +const buildProfileMigrationDatabaseUrl = (profileDatabaseUrl: string): string => { + const { url, optionKeys, existingOptions, configuredTimeZones } = + inspectProfileMigrationDatabaseUrl(profileDatabaseUrl); + + for (const key of optionKeys) url.searchParams.delete(key); + if (configuredTimeZones.length === 0) existingOptions.push(PROFILE_MIGRATION_TIME_ZONE_OPTION); + url.searchParams.set('options', existingOptions.join(' ')); + return url.href; +}; + +const assertProfileMigrationEnvironmentTimeZone = (env?: Record): void => { + const pgOptions = env?.PGOPTIONS?.trim(); + if (pgOptions) { + for (const timeZone of readPostgresOptionTimeZones(pgOptions, 'PGOPTIONS')) { + assertProfileMigrationTimeZone(timeZone, 'PGOPTIONS'); + } + } + const pgTimeZone = env?.PGTZ?.trim(); + if (pgTimeZone) assertProfileMigrationTimeZone(pgTimeZone, 'PGTZ'); +}; + +const buildProfileMigrationEnv = ( + profileDatabaseUrl: string, + env?: Record +): Record => { + assertProfileMigrationEnvironmentTimeZone(env); + return { ...(env ?? {}), DATABASE_URL: buildProfileMigrationDatabaseUrl(profileDatabaseUrl) }; +}; + +const buildProfileMigrationPreflightEnv = ( + profileDatabaseUrl: string, + env?: Record +): Record => { + inspectProfileMigrationDatabaseUrl(profileDatabaseUrl); + assertProfileMigrationEnvironmentTimeZone(env); + return { ...(env ?? {}), DATABASE_URL: profileDatabaseUrl }; +}; + +const PROFILE_MIGRATION_TIME_ZONE_PREFLIGHT = ` +import pg from 'pg'; +const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); +let connected = false; +try { + await client.connect(); + connected = true; + const result = await client.query("SELECT current_setting('TimeZone') AS timezone"); + if (result.rows[0]?.timezone !== '${PROFILE_MIGRATION_TIME_ZONE}') { + throw new Error('Profile migration refused: database session TimeZone does not match the required migration contract.'); + } +} finally { + if (connected) await client.end(); +} +`.trim(); + +export const buildProfileMigrationPreflightCommand = ( + workspaceRoot: string, + profileDatabaseUrl: string, + env?: Record +): BuildCommand => ({ + command: 'pnpm', + args: [ + '--filter', + '@sammo-ts/infra', + 'exec', + 'node', + '--input-type=module', + '--eval', + PROFILE_MIGRATION_TIME_ZONE_PREFLIGHT, + ], + cwd: workspaceRoot, + env: buildProfileMigrationPreflightEnv(profileDatabaseUrl, env), +}); + export const buildProfileMigrationCommand = ( workspaceRoot: string, profileDatabaseUrl: string, @@ -692,7 +883,7 @@ export const buildProfileMigrationCommand = ( command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], cwd: workspaceRoot, - env: { ...(env ?? {}), DATABASE_URL: profileDatabaseUrl }, + env: buildProfileMigrationEnv(profileDatabaseUrl, env), }); const mapRuntimeStates = (profileNames: string[], processNames: Map): ProfileRuntimeSnapshot[] => @@ -2269,7 +2460,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { onProgress?: BuildProgressObserver ): Promise>> { return this.buildRunner.run( - [buildProfileMigrationCommand(workspaceRoot, profileDatabaseUrl, this.processConfig.baseEnv)], + [ + buildProfileMigrationPreflightCommand(workspaceRoot, profileDatabaseUrl, this.processConfig.baseEnv), + buildProfileMigrationCommand(workspaceRoot, profileDatabaseUrl, this.processConfig.baseEnv), + ], onProgress, { signal: this.activeOperationAbortSignal } ); diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 60cc4069..cff3997e 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { buildProfileFrontendCommands, buildProfileMigrationCommand, + buildProfileMigrationPreflightCommand, buildProcessDefinitions, buildSharedProfileFrontendCommands, buildWorkspaceCommands, @@ -437,10 +438,89 @@ describe('buildWorkspaceCommands', () => { cwd: workspaceRoot, env: { NODE_ENV: 'production', - DATABASE_URL: databaseUrl, + DATABASE_URL: 'postgresql://integration.invalid/sammo?schema=che&options=-c+TimeZone%3DAsia%2FSeoul', }, }); }); + + it('preserves existing non-timezone options and adds the migration KST session', () => { + const command = buildProfileMigrationCommand( + '/srv/sammo/worktrees/0123456789abcdef', + 'postgresql://integration.invalid/sammo?schema=che&options=-c%20statement_timeout%3D30000' + ); + const migrationUrl = new URL(command.env?.DATABASE_URL ?? ''); + + expect(migrationUrl.searchParams.getAll('options')).toEqual(['-c statement_timeout=30000 -c TimeZone=Asia/Seoul']); + }); + + it('keeps an already explicit KST migration contract without adding another override', () => { + const command = buildProfileMigrationCommand( + '/srv/sammo/worktrees/0123456789abcdef', + 'postgresql://integration.invalid/sammo?schema=che&options=-c%20TimeZone%3DAsia%2FSeoul' + ); + const migrationUrl = new URL(command.env?.DATABASE_URL ?? ''); + + expect(migrationUrl.searchParams.getAll('options')).toEqual(['-c TimeZone=Asia/Seoul']); + }); + + it('fails closed on conflicting or ambiguous migration timezone sources without exposing the URL', () => { + const secretUrl = + 'postgresql://migration:super-secret@integration.invalid/sammo?schema=che&options=-c%20TimeZone%3DUTC'; + + for (const build of [ + () => buildProfileMigrationCommand('/srv/sammo/worktree', secretUrl), + () => + buildProfileMigrationCommand( + '/srv/sammo/worktree', + 'postgresql://integration.invalid/sammo?schema=che&timezone=UTC' + ), + () => + buildProfileMigrationCommand('/srv/sammo/worktree', 'postgresql://integration.invalid/sammo', { + PGOPTIONS: '-c statement_timeout=30000 --TimeZone=UTC', + }), + () => + buildProfileMigrationCommand('/srv/sammo/worktree', 'postgresql://integration.invalid/sammo', { + PGTZ: 'UTC', + }), + () => + buildProfileMigrationCommand( + '/srv/sammo/worktree', + 'postgresql://integration.invalid/sammo?options=--TimeZone%20UTC' + ), + ]) { + let error: unknown; + try { + build(); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('Profile migration refused'); + expect((error as Error).message).not.toContain('super-secret'); + expect((error as Error).message).not.toContain(secretUrl); + } + }); + + it('checks the unmodified runtime URL before building the separate migration-only URL', () => { + const profileDatabaseUrl = 'postgresql://integration.invalid/sammo?schema=che'; + const preflight = buildProfileMigrationPreflightCommand('/srv/sammo/worktree', profileDatabaseUrl); + const migration = buildProfileMigrationCommand('/srv/sammo/worktree', profileDatabaseUrl); + + expect(preflight.args.slice(0, 6)).toEqual([ + '--filter', + '@sammo-ts/infra', + 'exec', + 'node', + '--input-type=module', + '--eval', + ]); + expect(preflight.args.at(-1)).toContain("current_setting('TimeZone')"); + expect(preflight.env?.DATABASE_URL).toBe(profileDatabaseUrl); + expect(migration.env?.DATABASE_URL).toBe( + 'postgresql://integration.invalid/sammo?schema=che&options=-c+TimeZone%3DAsia%2FSeoul' + ); + expect(preflight.env?.DATABASE_URL).not.toBe(migration.env?.DATABASE_URL); + }); }); describe('buildProfileFrontendCommands', () => { diff --git a/app/gateway-api/test/profileDeployOperation.test.ts b/app/gateway-api/test/profileDeployOperation.test.ts index b50a5059..f19848b4 100644 --- a/app/gateway-api/test/profileDeployOperation.test.ts +++ b/app/gateway-api/test/profileDeployOperation.test.ts @@ -225,12 +225,26 @@ describe('profile DEPLOY operation', () => { expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_API_URL'); expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_SSE_URL'); expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_PROFILE'); - expect(commandGroups[1]?.map((command) => command.args)).toEqual([ - ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], + expect(commandGroups[1]?.[0]?.args.slice(0, 6)).toEqual([ + '--filter', + '@sammo-ts/infra', + 'exec', + 'node', + '--input-type=module', + '--eval', + ]); + expect(commandGroups[1]?.[0]?.args.at(-1)).toContain("current_setting('TimeZone')"); + expect(commandGroups[1]?.[1]?.args).toEqual([ + '--filter', + '@sammo-ts/infra', + 'prisma:migrate:deploy:game', ]); expect(commandGroups[1]?.[0]?.env?.DATABASE_URL).toBe( 'postgresql://user:encoded%23password@integration.invalid/sammo?schema=che' ); + expect(commandGroups[1]?.[1]?.env?.DATABASE_URL).toBe( + 'postgresql://user:encoded%23password@integration.invalid/sammo?schema=che&options=-c+TimeZone%3DAsia%2FSeoul' + ); expect(startedDefinitions).toHaveLength(backendProcessNames.length); for (const definition of startedDefinitions) { expect(definition.env?.DATABASE_URL).toBe( diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index 021a96e1..327a3702 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -39,7 +39,7 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, gatewaySchemaHead: '20260823010000_add_web_push_notifications', - gameSchemaHead: '20260824070000_game_outbox_utc_wall_timestamps', + gameSchemaHead: '20260824080000_vote_utc_wall_timestamps', }); }); diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index 791db1ca..4a71cdd0 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -97,12 +97,12 @@ model ReadModelOutbox { id BigInt @id @default(autoincrement()) payload Json attempts Int @default(0) - availableAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'")) @map("available_at") @db.Timestamp(3) + availableAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("available_at") @db.Timestamp(3) lockedAt DateTime? @map("locked_at") @db.Timestamp(3) lockOwner String? @map("lock_owner") deliveredAt DateTime? @map("delivered_at") @db.Timestamp(3) lastError String? @map("last_error") - createdAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'")) @map("created_at") @db.Timestamp(3) + createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3) @@index([deliveredAt, availableAt, id], map: "read_model_outbox_delivered_at_available_at_id_idx") @@map("read_model_outbox") @@ -116,12 +116,12 @@ model WebPushOutbox { year Int? month Int? attempts Int @default(0) - availableAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'")) @map("available_at") @db.Timestamp(3) + availableAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("available_at") @db.Timestamp(3) lockedAt DateTime? @map("locked_at") @db.Timestamp(3) lockOwner String? @map("lock_owner") deliveredAt DateTime? @map("delivered_at") @db.Timestamp(3) lastError String? @map("last_error") - createdAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'")) @map("created_at") @db.Timestamp(3) + createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3) @@index([deliveredAt, availableAt, id], map: "web_push_outbox_delivered_at_available_at_id_idx") @@map("web_push_outbox") @@ -901,8 +901,8 @@ model VotePoll { endAt DateTime? @map("end_at") endTick BigInt? @map("end_tick") closedAt DateTime? @map("closed_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3) + updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @updatedAt @map("updated_at") @db.Timestamp(3) votes Vote[] comments VoteComment[] @@ -916,7 +916,7 @@ model Vote { generalId Int @map("general_id") nationId Int @map("nation_id") selection Json - createdAt DateTime @default(now()) @map("created_at") + createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3) poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade) @@ -933,7 +933,7 @@ model VoteComment { generalName String @map("general_name") nationName String @map("nation_name") text String - createdAt DateTime @default(now()) @map("created_at") + createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3) poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade) diff --git a/packages/infra/prisma/migrations/20260824080000_vote_utc_wall_timestamps/migration.sql b/packages/infra/prisma/migrations/20260824080000_vote_utc_wall_timestamps/migration.sql new file mode 100644 index 00000000..0f9a8be0 --- /dev/null +++ b/packages/infra/prisma/migrations/20260824080000_vote_utc_wall_timestamps/migration.sql @@ -0,0 +1,15 @@ +-- Vote mutations use raw SQL to preserve their locked transaction and Ref +-- ordering. Game connections retain the Ref-compatible Seoul session, so make +-- timestamp-omitting insert fallbacks safe for older and future writers. +-- Historical rows are intentionally preserved because +-- Prisma-seeded UTC values and DB-default Seoul values have no durable +-- provenance marker that would allow a safe blanket rewrite. +ALTER TABLE "vote_poll" + ALTER COLUMN "created_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + ALTER COLUMN "updated_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'); + +ALTER TABLE "vote" + ALTER COLUMN "created_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'); + +ALTER TABLE "vote_comment" + ALTER COLUMN "created_at" SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'); diff --git a/packages/infra/prisma/migrations/README.md b/packages/infra/prisma/migrations/README.md index 6af16e99..0d1cf44e 100644 --- a/packages/infra/prisma/migrations/README.md +++ b/packages/infra/prisma/migrations/README.md @@ -35,6 +35,11 @@ chain을 적용하고 두 번째 실행은 `No pending migrations to apply`여 - `read_model_revision_meta.id=1`의 `coverage_version=0` - `diplomacy`, `event`, `log_entry`, `error_log` - auction, board, vote, yearbook, archive와 inheritance table +- `vote_poll.created_at/updated_at`, `vote.created_at`, `vote_comment.created_at`의 + 신규 raw-SQL fallback은 KST game session에서도 UTC wall 값을 기록한다. 현재 + writer는 JavaScript `Date`를 명시하고, 이전 writer 형태의 column 생략도 새 + default로 안전해야 한다. 기존 vote timestamp는 Prisma UTC와 raw KST 출처를 + 구분할 표식이 없어 소급 이동하지 않는다. - `nation.chief_general_id` - `city.trade` nullable, `city.trust` REAL - `auction_bid.meta` JSONB NOT NULL @@ -62,6 +67,26 @@ WebPush `created_at` 보존, 두 pending outbox의 requeue·lease 해제, delive 보존, target checksum과 이전 DML shape 호환성을 확인합니다. 이전 binary를 별도 build해 실행하거나 down migration을 제공한다는 뜻은 아닙니다. +운영 release-controller의 게임 migration 명령은 profile runtime URL을 바꾸지 +않고 migration 연결에만 `options=-c TimeZone=Asia/Seoul`을 추가합니다. 이미 +명시된 `DATABASE_URL` option/query, `PGOPTIONS` 또는 `PGTZ`가 다른 timezone을 +요구하면 마지막 옵션으로 덮지 않고 migration 시작 전에 실패합니다. 그 다음 +변경하지 않은 원본 profile URL로 `current_setting('TimeZone')`을 조회해 기존 +writer session이 실제로 `Asia/Seoul`인지 확인한 뒤에만, KST option을 고정한 +별도 URL로 Prisma migration을 실행합니다. 이는 이미 배포된 migration checksum을 +보존하면서 legacy game wall-clock provenance가 다른 과거 시각을 0700 migration이 +잘못 재해석하는 일을 막습니다. + +실제 fail-closed 경계는 timezone option이 없는 일회성 UTC-default role URL을 +`PROFILE_MIGRATION_UTC_DATABASE_URL`로 주입하고 다음처럼 재현합니다. 이 URL은 +격리 DB의 disposable role만 사용하며 문서·로그에 값을 남기지 않습니다. +marker는 `external_fixture`로 등록되어 일반 조건부 runner가 일회성 role을 만들거나 +이 테스트를 실행하지 않으며, 위 URL을 준비한 명시적 실행에서만 활성화됩니다. + +```sh +pnpm --filter @sammo-ts/gateway-api test profileMigrationTimezone.integration.test.ts +``` + ## NPC selection 중복 owner preflight `20260731000000_add_npc_selection_token`은 `general.user_id` 중복을 발견하면 diff --git a/release-manifest.json b/release-manifest.json index cf4fb87f..773e420f 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -2,6 +2,6 @@ "formatVersion": 1, "controllerProtocol": 2, "gatewaySchemaHead": "20260823010000_add_web_push_notifications", - "gameSchemaHead": "20260824070000_game_outbox_utc_wall_timestamps", + "gameSchemaHead": "20260824080000_vote_utc_wall_timestamps", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] }