fix: 투표 UTC wall과 migration 시간대 경계를 고정한다

This commit is contained in:
2026-08-24 09:18:42 +00:00
parent 821b8a0723
commit 7a1254cb3a
15 changed files with 667 additions and 25 deletions
+32 -7
View File
@@ -394,10 +394,27 @@ export const voteRouter = router({
? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } }) ? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } })
: null; : null;
const nationName = nation?.name ?? '재야'; const nationName = nation?.name ?? '재야';
const createdAt = new Date();
await ctx.db.$queryRaw(GamePrisma.sql` await ctx.db.$queryRaw(GamePrisma.sql`
INSERT INTO vote_comment (vote_id, general_id, nation_id, general_name, nation_name, text) INSERT INTO vote_comment (
VALUES (${input.voteId}, ${general.id}, ${general.nationId}, ${general.name}, ${nationName}, ${input.text}) 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 }; return { ok: true };
@@ -430,6 +447,7 @@ export const voteRouter = router({
if (endAt && endAt < gameTime.now) { if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
} }
const operationalAt = new Date();
let multipleOptions = input.multipleOptions; let multipleOptions = input.multipleOptions;
if (multipleOptions < 0) { if (multipleOptions < 0) {
@@ -442,7 +460,7 @@ export const voteRouter = router({
if (input.closePrevious) { if (input.closePrevious) {
await ctx.db.$queryRaw(GamePrisma.sql` await ctx.db.$queryRaw(GamePrisma.sql`
UPDATE vote_poll UPDATE vote_poll
SET closed_at = ${gameTime.now}, updated_at = NOW() SET closed_at = ${gameTime.now}, updated_at = ${operationalAt}
WHERE closed_at IS NULL WHERE closed_at IS NULL
`); `);
} }
@@ -459,7 +477,9 @@ export const voteRouter = router({
start_at, start_at,
start_tick, start_tick,
end_at, end_at,
end_tick end_tick,
created_at,
updated_at
) )
VALUES ( VALUES (
${input.title}, ${input.title},
@@ -472,7 +492,9 @@ export const voteRouter = router({
${gameTime.now}, ${gameTime.now},
${gameTime.tick === null ? null : BigInt(gameTime.tick)}, ${gameTime.tick === null ? null : BigInt(gameTime.tick)},
${endAt}, ${endAt},
${toGameTickOrNull(gameTime, endAt)} ${toGameTickOrNull(gameTime, endAt)},
${operationalAt},
${operationalAt}
) )
`); `);
@@ -547,6 +569,7 @@ export const voteRouter = router({
if (endAt && endAt < gameTime.now) { if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
} }
const updatedAt = new Date();
if ( if (
input.title === undefined && input.title === undefined &&
@@ -569,7 +592,7 @@ export const voteRouter = router({
reveal_mode = COALESCE(${input.revealMode}, reveal_mode), reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
end_at = ${endAt ?? poll.end_at}, end_at = ${endAt ?? poll.end_at},
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick}, end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
updated_at = NOW() updated_at = ${updatedAt}
WHERE id = ${input.voteId} WHERE id = ${input.voteId}
`); `);
@@ -581,9 +604,11 @@ export const voteRouter = router({
closePoll: adminProcedure closePoll: adminProcedure
.input(z.object({ voteId: z.number().int().positive() })) .input(z.object({ voteId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const gameTime = await loadCurrentGameTime(ctx.db);
const updatedAt = new Date();
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql` const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
UPDATE vote_poll 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} WHERE id = ${input.voteId}
RETURNING id RETURNING id
`); `);
@@ -99,6 +99,8 @@ integration('API input event boundary', () => {
actorUserId: 'user-7', actorUserId: 'user-7',
attempts: 1, 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'); 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 () => { it('reuses the same engine child event but rejects a changed retry payload', async () => {
const transport = new DatabaseTurnDaemonTransport(db, 100); const transport = new DatabaseTurnDaemonTransport(db, 100);
const requestId = 'integration:api:engine-child'; const requestId = 'integration:api:engine-child';
const acceptedWindowStart = Date.now();
await transport.sendCommand({ type: 'vacation', requestId, generalId: 7 }); 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: 7 })).resolves.toBe(requestId);
await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 8 })).rejects.toBeInstanceOf( await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 8 })).rejects.toBeInstanceOf(
ConflictingTurnDaemonCommandError ConflictingTurnDaemonCommandError
@@ -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<void>) | undefined;
const cleanup = async (): Promise<void> => {
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<Array<{ timeZone: string }>>`
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);
});
});
+57
View File
@@ -130,6 +130,9 @@ const buildContext = (options: {
if (text.includes('INSERT INTO vote_comment')) { if (text.includes('INSERT INTO vote_comment')) {
return []; return [];
} }
if (text.includes('UPDATE vote_poll')) {
return [{ id: 1 }];
}
return []; return [];
}); });
const db = { const db = {
@@ -289,6 +292,60 @@ describe('vote router actor and permission boundaries', () => {
expect(insert?.values).not.toContain('관리자 장수'); 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 () => { it('reports the current world develcost as the legacy five-times survey reward', async () => {
const fixture = buildContext({ metaDevelCost: 30, configConst: { develCost: 0 } }); const fixture = buildContext({ metaDevelCost: 30, configConst: { develCost: 0 } });
@@ -2479,12 +2479,14 @@ const insertVoteSelection = async (
if (!ctx.commandDb) { if (!ctx.commandDb) {
return 'missing'; return 'missing';
} }
const createdAt = new Date();
const rows = await ctx.commandDb.$queryRaw<Array<{ id: number }>>(GamePrisma.sql` const rows = await ctx.commandDb.$queryRaw<Array<{ id: number }>>(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, SELECT poll.id,
${general.id}, ${general.id},
${general.nationId}, ${general.nationId},
CAST(${JSON.stringify(selection)} AS jsonb) CAST(${JSON.stringify(selection)} AS jsonb),
${createdAt}
FROM vote_poll poll FROM vote_poll poll
WHERE poll.id = ${command.voteId} WHERE poll.id = ${command.voteId}
ON CONFLICT (vote_id, general_id) DO NOTHING ON CONFLICT (vote_id, general_id) DO NOTHING
@@ -286,4 +286,37 @@ integration('game cancellation transaction', () => {
generalMode: 'DELETE', 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<Array<{ timeZone: string }>>`
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,
});
});
}); });
+14 -1
View File
@@ -222,11 +222,12 @@ describe('voteReward command', () => {
let voteInserted = false; let voteInserted = false;
let voteQueryCount = 0; let voteQueryCount = 0;
let voteInsertQuery: { strings: readonly string[]; values: readonly unknown[] } | undefined;
const commandDb = { const commandDb = {
auction: { auction: {
findMany: async () => [], findMany: async () => [],
}, },
$queryRaw: async (query: { strings: readonly string[] }) => { $queryRaw: async (query: { strings: readonly string[]; values: readonly unknown[] }) => {
voteQueryCount += 1; voteQueryCount += 1;
if (query.strings.join(' ').includes('SELECT options')) { if (query.strings.join(' ').includes('SELECT options')) {
return [ return [
@@ -239,6 +240,7 @@ describe('voteReward command', () => {
}, },
]; ];
} }
voteInsertQuery = query;
if (voteInserted) return []; if (voteInserted) return [];
voteInserted = true; voteInserted = true;
return [{ id: 11 }]; return [{ id: 11 }];
@@ -255,12 +257,23 @@ describe('voteReward command', () => {
acceptedGameTick: 0, acceptedGameTick: 0,
}; };
const writerWindowStart = Date.now();
const result = await handler.handle(command, { db: commandDb as any }); const result = await handler.handle(command, { db: commandDb as any });
const writerWindowEnd = Date.now();
expect(result && result.type).toBe('voteReward'); expect(result && result.type).toBe('voteReward');
if (!result || result.type !== 'voteReward' || !result.ok) { if (!result || result.type !== 'voteReward' || !result.ok) {
throw new Error('voteReward result missing'); throw new Error('voteReward result missing');
} }
expect(result.awardedUnique).toBe(true); 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); const updated = world.getGeneralById(1);
// ENGINE is the single reward linearization point, so it uses the // ENGINE is the single reward linearization point, so it uses the
@@ -684,6 +684,197 @@ export const buildWorkspaceCommands = (
return commands; 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<string, string>): 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<string, string>
): Record<string, string> => {
assertProfileMigrationEnvironmentTimeZone(env);
return { ...(env ?? {}), DATABASE_URL: buildProfileMigrationDatabaseUrl(profileDatabaseUrl) };
};
const buildProfileMigrationPreflightEnv = (
profileDatabaseUrl: string,
env?: Record<string, string>
): Record<string, string> => {
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<string, string>
): 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 = ( export const buildProfileMigrationCommand = (
workspaceRoot: string, workspaceRoot: string,
profileDatabaseUrl: string, profileDatabaseUrl: string,
@@ -692,7 +883,7 @@ export const buildProfileMigrationCommand = (
command: 'pnpm', command: 'pnpm',
args: ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], args: ['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
cwd: workspaceRoot, cwd: workspaceRoot,
env: { ...(env ?? {}), DATABASE_URL: profileDatabaseUrl }, env: buildProfileMigrationEnv(profileDatabaseUrl, env),
}); });
const mapRuntimeStates = (profileNames: string[], processNames: Map<string, boolean>): ProfileRuntimeSnapshot[] => const mapRuntimeStates = (profileNames: string[], processNames: Map<string, boolean>): ProfileRuntimeSnapshot[] =>
@@ -2269,7 +2460,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
onProgress?: BuildProgressObserver onProgress?: BuildProgressObserver
): Promise<Awaited<ReturnType<BuildRunner['run']>>> { ): Promise<Awaited<ReturnType<BuildRunner['run']>>> {
return this.buildRunner.run( return this.buildRunner.run(
[buildProfileMigrationCommand(workspaceRoot, profileDatabaseUrl, this.processConfig.baseEnv)], [
buildProfileMigrationPreflightCommand(workspaceRoot, profileDatabaseUrl, this.processConfig.baseEnv),
buildProfileMigrationCommand(workspaceRoot, profileDatabaseUrl, this.processConfig.baseEnv),
],
onProgress, onProgress,
{ signal: this.activeOperationAbortSignal } { signal: this.activeOperationAbortSignal }
); );
+81 -1
View File
@@ -5,6 +5,7 @@ import path from 'node:path';
import { import {
buildProfileFrontendCommands, buildProfileFrontendCommands,
buildProfileMigrationCommand, buildProfileMigrationCommand,
buildProfileMigrationPreflightCommand,
buildProcessDefinitions, buildProcessDefinitions,
buildSharedProfileFrontendCommands, buildSharedProfileFrontendCommands,
buildWorkspaceCommands, buildWorkspaceCommands,
@@ -437,10 +438,89 @@ describe('buildWorkspaceCommands', () => {
cwd: workspaceRoot, cwd: workspaceRoot,
env: { env: {
NODE_ENV: 'production', 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', () => { describe('buildProfileFrontendCommands', () => {
@@ -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_API_URL');
expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_SSE_URL'); expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_SSE_URL');
expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_PROFILE'); expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_PROFILE');
expect(commandGroups[1]?.map((command) => command.args)).toEqual([ expect(commandGroups[1]?.[0]?.args.slice(0, 6)).toEqual([
['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'], '--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( expect(commandGroups[1]?.[0]?.env?.DATABASE_URL).toBe(
'postgresql://user:encoded%23password@integration.invalid/sammo?schema=che' '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); expect(startedDefinitions).toHaveLength(backendProcessNames.length);
for (const definition of startedDefinitions) { for (const definition of startedDefinitions) {
expect(definition.env?.DATABASE_URL).toBe( expect(definition.env?.DATABASE_URL).toBe(
+1 -1
View File
@@ -39,7 +39,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260823010000_add_web_push_notifications', gatewaySchemaHead: '20260823010000_add_web_push_notifications',
gameSchemaHead: '20260824070000_game_outbox_utc_wall_timestamps', gameSchemaHead: '20260824080000_vote_utc_wall_timestamps',
}); });
}); });
+8 -8
View File
@@ -97,12 +97,12 @@ model ReadModelOutbox {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
payload Json payload Json
attempts Int @default(0) 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) lockedAt DateTime? @map("locked_at") @db.Timestamp(3)
lockOwner String? @map("lock_owner") lockOwner String? @map("lock_owner")
deliveredAt DateTime? @map("delivered_at") @db.Timestamp(3) deliveredAt DateTime? @map("delivered_at") @db.Timestamp(3)
lastError String? @map("last_error") 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") @@index([deliveredAt, availableAt, id], map: "read_model_outbox_delivered_at_available_at_id_idx")
@@map("read_model_outbox") @@map("read_model_outbox")
@@ -116,12 +116,12 @@ model WebPushOutbox {
year Int? year Int?
month Int? month Int?
attempts Int @default(0) 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) lockedAt DateTime? @map("locked_at") @db.Timestamp(3)
lockOwner String? @map("lock_owner") lockOwner String? @map("lock_owner")
deliveredAt DateTime? @map("delivered_at") @db.Timestamp(3) deliveredAt DateTime? @map("delivered_at") @db.Timestamp(3)
lastError String? @map("last_error") 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") @@index([deliveredAt, availableAt, id], map: "web_push_outbox_delivered_at_available_at_id_idx")
@@map("web_push_outbox") @@map("web_push_outbox")
@@ -901,8 +901,8 @@ model VotePoll {
endAt DateTime? @map("end_at") endAt DateTime? @map("end_at")
endTick BigInt? @map("end_tick") endTick BigInt? @map("end_tick")
closedAt DateTime? @map("closed_at") closedAt DateTime? @map("closed_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3)
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @updatedAt @map("updated_at") @db.Timestamp(3)
votes Vote[] votes Vote[]
comments VoteComment[] comments VoteComment[]
@@ -916,7 +916,7 @@ model Vote {
generalId Int @map("general_id") generalId Int @map("general_id")
nationId Int @map("nation_id") nationId Int @map("nation_id")
selection Json 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) poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade)
@@ -933,7 +933,7 @@ model VoteComment {
generalName String @map("general_name") generalName String @map("general_name")
nationName String @map("nation_name") nationName String @map("nation_name")
text String 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) poll VotePoll @relation(fields: [voteId], references: [id], onDelete: Cascade)
@@ -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');
@@ -35,6 +35,11 @@ chain을 적용하고 두 번째 실행은 `No pending migrations to apply`여
- `read_model_revision_meta.id=1``coverage_version=0` - `read_model_revision_meta.id=1``coverage_version=0`
- `diplomacy`, `event`, `log_entry`, `error_log` - `diplomacy`, `event`, `log_entry`, `error_log`
- auction, board, vote, yearbook, archive와 inheritance table - 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` - `nation.chief_general_id`
- `city.trade` nullable, `city.trust` REAL - `city.trade` nullable, `city.trust` REAL
- `auction_bid.meta` JSONB NOT NULL - `auction_bid.meta` JSONB NOT NULL
@@ -62,6 +67,26 @@ WebPush `created_at` 보존, 두 pending outbox의 requeue·lease 해제, delive
보존, target checksum과 이전 DML shape 호환성을 확인합니다. 이전 binary를 별도 보존, target checksum과 이전 DML shape 호환성을 확인합니다. 이전 binary를 별도
build해 실행하거나 down migration을 제공한다는 뜻은 아닙니다. 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 ## NPC selection 중복 owner preflight
`20260731000000_add_npc_selection_token``general.user_id` 중복을 발견하면 `20260731000000_add_npc_selection_token``general.user_id` 중복을 발견하면
+1 -1
View File
@@ -2,6 +2,6 @@
"formatVersion": 1, "formatVersion": 1,
"controllerProtocol": 2, "controllerProtocol": 2,
"gatewaySchemaHead": "20260823010000_add_web_push_notifications", "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"] "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
} }