fix: 투표 UTC wall과 migration 시간대 경계를 고정한다
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+15
@@ -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` 중복을 발견하면
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user