fix: 투표 UTC wall과 migration 시간대 경계를 고정한다
This commit is contained in:
@@ -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<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 = (
|
||||
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<string, boolean>): ProfileRuntimeSnapshot[] =>
|
||||
@@ -2269,7 +2460,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
onProgress?: BuildProgressObserver
|
||||
): Promise<Awaited<ReturnType<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,
|
||||
{ signal: this.activeOperationAbortSignal }
|
||||
);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user