feat: 과거 장수 전투 결과 보존 이관 추가

preserved batres 파일을 기수 단위로 검증·체크포인트하고 과거 장수 상세에 연결한다. check-plan에는 전체 이전 항목과 정보 설명을 함께 노출한다.
This commit is contained in:
2026-08-18 13:15:33 +00:00
parent 2c4d52208b
commit c04a93a76c
20 changed files with 1359 additions and 51 deletions
@@ -0,0 +1,319 @@
import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
import path from 'node:path';
import readline from 'node:readline';
import { TextDecoder } from 'node:util';
import type { LegacyArchiveProfile } from './game.js';
import type { MigrationSourceIdentity } from './incremental.js';
const MAX_BATTLE_RESULT_FILE_BYTES = 4 * 1024 * 1024;
const SSH_HOST = /^(?:[a-zA-Z0-9._-]+@)?[a-zA-Z0-9._-]+$/u;
const HASH = /^[a-f0-9]{64}$/u;
export interface BattleResultSourceConfig {
kind: 'local' | 'ssh';
directory: string;
sshHost?: string;
identity: MigrationSourceIdentity;
}
export interface BattleResultFileDescriptor {
serverId: string;
generalNo: number;
sourceBytes: number;
contentHash: string;
}
export interface BattleResultSeasonManifest {
serverId: string;
files: BattleResultFileDescriptor[];
fileCount: number;
totalBytes: number;
manifestHash: string;
}
export interface BattleResultFile extends BattleResultFileDescriptor {
content: string;
lineCount: number;
}
type RemoteDescriptor = {
serverId: string;
generalNo: number;
sourceBytes: number;
contentHash: string;
contentBase64?: string;
};
const REMOTE_READER = String.raw`
import base64, hashlib, json, os, re, sys
MAX_BYTES = 4 * 1024 * 1024
action, root_input, profile = sys.argv[1:4]
selected = set(json.loads(base64.urlsafe_b64decode(sys.argv[4]).decode('utf-8'))) if len(sys.argv) > 4 else set()
root = os.path.realpath(root_input)
season_re = re.compile(r'^' + re.escape(profile) + r'_[A-Za-z0-9_-]{1,96}$')
file_re = re.compile(r'^batres([0-9]+)\.txt$')
if not os.path.isdir(root):
raise RuntimeError('preserved battle-result directory is not readable')
for season in sorted(os.scandir(root), key=lambda item: item.name):
if not season.is_dir(follow_symlinks=False) or not season_re.fullmatch(season.name):
continue
if action == 'read' and season.name not in selected:
continue
for item in sorted(os.scandir(season.path), key=lambda entry: entry.name):
match = file_re.fullmatch(item.name)
if not match or not item.is_file(follow_symlinks=False):
continue
size = item.stat(follow_symlinks=False).st_size
if size > MAX_BYTES:
raise RuntimeError(f'battle-result file exceeds {MAX_BYTES} bytes: {season.name}/{item.name}')
digest = hashlib.sha256()
content = bytearray()
with open(item.path, 'rb') as handle:
while True:
chunk = handle.read(1024 * 1024)
if not chunk:
break
digest.update(chunk)
content.extend(chunk)
content.decode('utf-8')
if b'\0' in content:
raise RuntimeError(f'battle-result file contains NUL: {season.name}/{item.name}')
result = {
'serverId': season.name,
'generalNo': int(match.group(1)),
'sourceBytes': size,
'contentHash': digest.hexdigest(),
}
if action == 'read':
result['contentBase64'] = base64.b64encode(content).decode('ascii')
print(json.dumps(result, ensure_ascii=True), flush=True)
`;
const safeSourceDirectory = (directory: string): string => {
if (!path.isAbsolute(directory) || directory.length > 4096 || /[\0\r\n]/u.test(directory)) {
throw new Error('Preserved battle-result directory must be a safe absolute path');
}
return directory;
};
export const resolveBattleResultSourceConfig = async (
input: { directory: string; sshHost?: string },
sourceKey: string,
profile: LegacyArchiveProfile,
configDirectory: string
): Promise<BattleResultSourceConfig> => {
const sshHost = input.sshHost?.trim();
let directory: string;
let kind: BattleResultSourceConfig['kind'];
if (sshHost) {
if (!SSH_HOST.test(sshHost) || sshHost.startsWith('-')) {
throw new Error('battleResults.sshHost must be a safe SSH host or configured alias');
}
directory = safeSourceDirectory(input.directory.trim());
kind = 'ssh';
} else {
directory = await realpath(path.resolve(configDirectory, input.directory));
safeSourceDirectory(directory);
const info = await lstat(directory);
if (!info.isDirectory()) throw new Error('battleResults.directory must be a directory');
kind = 'local';
}
const fingerprint = createHash('sha256')
.update(JSON.stringify({ kind, directory, sshHost: sshHost ?? null, profile }))
.digest('hex');
return {
kind,
directory,
...(sshHost ? { sshHost } : {}),
identity: { key: `${sourceKey}:battle-results`, fingerprint },
};
};
const localDescriptors = async (
source: BattleResultSourceConfig,
profile: LegacyArchiveProfile,
selected?: ReadonlySet<string>
): Promise<RemoteDescriptor[]> => {
const seasonPattern = new RegExp(`^${profile}_[A-Za-z0-9_-]{1,96}$`, 'u');
const filePattern = /^batres([0-9]+)\.txt$/u;
const result: RemoteDescriptor[] = [];
for (const season of (await readdir(source.directory, { withFileTypes: true })).sort((a, b) =>
a.name.localeCompare(b.name)
)) {
if (!season.isDirectory() || !seasonPattern.test(season.name) || (selected && !selected.has(season.name))) {
continue;
}
const seasonPath = path.join(source.directory, season.name);
for (const file of (await readdir(seasonPath, { withFileTypes: true })).sort((a, b) =>
a.name.localeCompare(b.name)
)) {
const match = filePattern.exec(file.name);
if (!match || !file.isFile()) continue;
const filePath = path.join(seasonPath, file.name);
const info = await lstat(filePath);
if (!info.isFile() || info.isSymbolicLink()) continue;
if (info.size > MAX_BATTLE_RESULT_FILE_BYTES) {
throw new Error(
`Battle-result file exceeds ${MAX_BATTLE_RESULT_FILE_BYTES} bytes: ${season.name}/${file.name}`
);
}
const content = await readFile(filePath);
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(content);
if (decoded.includes('\0')) {
throw new Error(`Battle-result file contains NUL: ${season.name}/${file.name}`);
}
result.push({
serverId: season.name,
generalNo: Number(match[1]),
sourceBytes: info.size,
contentHash: createHash('sha256').update(content).digest('hex'),
...(selected ? { contentBase64: content.toString('base64') } : {}),
});
}
}
return result;
};
const shellQuote = (value: string): string => `'${value.replaceAll("'", `'"'"'`)}'`;
const remoteDescriptors = async (
source: BattleResultSourceConfig,
profile: LegacyArchiveProfile,
selected?: ReadonlySet<string>
): Promise<RemoteDescriptor[]> => {
if (!source.sshHost) throw new Error('SSH battle-result source is missing its host');
const action = selected ? 'read' : 'list';
const encodedSelection = Buffer.from(JSON.stringify([...(selected ?? [])]), 'utf8').toString('base64');
const remoteCommand = ['python3', '-c', REMOTE_READER, action, source.directory, profile, encodedSelection]
.map(shellQuote)
.join(' ');
const child = spawn('ssh', ['-C', '--', source.sshHost, remoteCommand], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk: string) => {
if (stderr.length < 8192) stderr += chunk.slice(0, 8192 - stderr.length);
});
const exit = new Promise<number>((resolve, reject) => {
child.once('error', reject);
child.once('close', (code) => resolve(code ?? 1));
});
const output: RemoteDescriptor[] = [];
const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
try {
for await (const line of lines) {
if (!line.trim()) continue;
const parsed = JSON.parse(line) as RemoteDescriptor;
output.push(parsed);
}
} catch (error) {
child.kill();
throw new Error('Could not parse the preserved battle-result SSH stream', { cause: error });
}
const code = await exit;
if (code !== 0) {
throw new Error(`Preserved battle-result SSH scan failed (${code}): ${stderr.trim() || 'no error text'}`);
}
return output;
};
const descriptors = (
source: BattleResultSourceConfig,
profile: LegacyArchiveProfile,
selected?: ReadonlySet<string>
): Promise<RemoteDescriptor[]> =>
source.kind === 'local'
? localDescriptors(source, profile, selected)
: remoteDescriptors(source, profile, selected);
const validateDescriptor = (descriptor: RemoteDescriptor, profile: LegacyArchiveProfile): void => {
if (!new RegExp(`^${profile}_[A-Za-z0-9_-]{1,96}$`, 'u').test(descriptor.serverId)) {
throw new Error(`Invalid battle-result server ID: ${descriptor.serverId}`);
}
if (!Number.isSafeInteger(descriptor.generalNo) || descriptor.generalNo < 0) {
throw new Error(`Invalid battle-result general number for ${descriptor.serverId}`);
}
if (
!Number.isSafeInteger(descriptor.sourceBytes) ||
descriptor.sourceBytes < 0 ||
descriptor.sourceBytes > MAX_BATTLE_RESULT_FILE_BYTES ||
!HASH.test(descriptor.contentHash)
) {
throw new Error(`Invalid battle-result descriptor for ${descriptor.serverId}/${descriptor.generalNo}`);
}
};
const manifestFor = (serverId: string, files: BattleResultFileDescriptor[]): BattleResultSeasonManifest => {
files.sort((left, right) => left.generalNo - right.generalNo);
const manifest = createHash('sha256');
for (const file of files) {
manifest.update(`${file.generalNo}\0${file.sourceBytes}\0${file.contentHash}\n`);
}
return {
serverId,
files,
fileCount: files.length,
totalBytes: files.reduce((sum, file) => sum + file.sourceBytes, 0),
manifestHash: manifest.digest('hex'),
};
};
export const listBattleResultSeasons = async (
source: BattleResultSourceConfig,
profile: LegacyArchiveProfile
): Promise<BattleResultSeasonManifest[]> => {
const grouped = new Map<string, BattleResultFileDescriptor[]>();
for (const descriptor of await descriptors(source, profile)) {
validateDescriptor(descriptor, profile);
const files = grouped.get(descriptor.serverId) ?? [];
if (files.some((file) => file.generalNo === descriptor.generalNo)) {
throw new Error(`Duplicate battle-result file for ${descriptor.serverId}/${descriptor.generalNo}`);
}
files.push(descriptor);
grouped.set(descriptor.serverId, files);
}
return [...grouped.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([serverId, files]) => manifestFor(serverId, files));
};
export const readBattleResultSeason = async (
source: BattleResultSourceConfig,
profile: LegacyArchiveProfile,
serverId: string
): Promise<{ manifest: BattleResultSeasonManifest; files: BattleResultFile[] }> => {
const files: BattleResultFile[] = [];
const decoder = new TextDecoder('utf-8', { fatal: true });
for (const descriptor of await descriptors(source, profile, new Set([serverId]))) {
validateDescriptor(descriptor, profile);
if (descriptor.serverId !== serverId || typeof descriptor.contentBase64 !== 'string') {
throw new Error(`Unexpected battle-result file while reading ${serverId}`);
}
const bytes = Buffer.from(descriptor.contentBase64, 'base64');
if (
bytes.byteLength !== descriptor.sourceBytes ||
createHash('sha256').update(bytes).digest('hex') !== descriptor.contentHash
) {
throw new Error(`Battle-result content changed while reading ${serverId}/${descriptor.generalNo}`);
}
const content = decoder.decode(bytes);
if (content.includes('\0'))
throw new Error(`Battle-result file contains NUL: ${serverId}/${descriptor.generalNo}`);
files.push({
serverId,
generalNo: descriptor.generalNo,
sourceBytes: descriptor.sourceBytes,
contentHash: descriptor.contentHash,
content,
lineCount: content.split(/\r?\n/u).filter((line) => line.length > 0).length,
});
}
return { manifest: manifestFor(serverId, files), files };
};
@@ -0,0 +1,316 @@
import type { Pool as PgPool, PoolClient } from 'pg';
import {
listBattleResultSeasons,
readBattleResultSeason,
type BattleResultSeasonManifest,
type BattleResultSourceConfig,
} from './battleResultSource.js';
import { upsertRows, withMigrationLock, type TargetRow } from './db.js';
import type { LegacyArchiveProfile } from './game.js';
import { validateSourceIdentity, type MigrationExecutionOptions } from './incremental.js';
interface StoredSeasonCheckpoint {
sourceFingerprint: string;
serverId: string;
manifestHash: string;
fileCount: number;
totalBytes: number;
}
export interface BattleResultMigrationSummary {
command: 'battle-results';
apply: boolean;
mode: 'full' | 'incremental';
sourceKey: string;
importRunId: string | null;
counts: {
discoveredSeasons: number;
discoveredFiles: number;
discoveredBytes: number;
unchangedSeasons: number;
pendingSeasons: number;
pendingFiles: number;
pendingBytes: number;
importedSeasons: number;
importedFiles: number;
importedLines: number;
importedBytes: number;
};
progress: Record<string, { status: 'UNCHANGED' | 'PENDING' | 'IMPORTED'; files: number; bytes: number }>;
}
const loadCheckpoints = async (
client: PoolClient,
profile: LegacyArchiveProfile,
sourceKey: string
): Promise<Map<string, StoredSeasonCheckpoint>> => {
const result = await client.query<{
source_fingerprint: string;
server_id: string;
manifest_hash: string;
file_count: number;
total_bytes: string;
}>(
`SELECT "source_fingerprint", "server_id", "manifest_hash", "file_count", "total_bytes"
FROM "legacy_archive"."battle_result_import_checkpoint"
WHERE "source_profile" = $1 AND "source_key" = $2`,
[profile, sourceKey]
);
return new Map(
result.rows.map((row) => [
row.server_id,
{
sourceFingerprint: row.source_fingerprint,
serverId: row.server_id,
manifestHash: row.manifest_hash,
fileCount: Number(row.file_count),
totalBytes: Number(row.total_bytes),
},
])
);
};
const sameManifest = (checkpoint: StoredSeasonCheckpoint, manifest: BattleResultSeasonManifest): boolean =>
checkpoint.manifestHash === manifest.manifestHash &&
checkpoint.fileCount === manifest.fileCount &&
checkpoint.totalBytes === manifest.totalBytes;
const upsertBattleResultRows = async (
client: PoolClient,
profile: LegacyArchiveProfile,
manifest: BattleResultSeasonManifest,
source: BattleResultSourceConfig,
importRunId: string
): Promise<{ files: number; lines: number; bytes: number }> => {
const loaded = await readBattleResultSeason(source, profile, manifest.serverId);
if (
loaded.manifest.manifestHash !== manifest.manifestHash ||
loaded.manifest.fileCount !== manifest.fileCount ||
loaded.manifest.totalBytes !== manifest.totalBytes
) {
throw new Error(`Battle-result season changed after preflight: ${manifest.serverId}`);
}
await client.query(
`DELETE FROM "legacy_archive"."general_battle_result"
WHERE "source_profile" = $1 AND "server_id" = $2`,
[profile, manifest.serverId]
);
let batch: TargetRow[] = [];
let batchBytes = 0;
let lines = 0;
const flush = async (): Promise<void> => {
await upsertRows(client, 'legacy_archive.general_battle_result', batch, [
'source_profile',
'server_id',
'general_no',
]);
batch = [];
batchBytes = 0;
};
for (const file of loaded.files) {
if (batch.length >= 100 || batchBytes + file.sourceBytes > 4 * 1024 * 1024) await flush();
batch.push({
source_profile: profile,
server_id: file.serverId,
general_no: file.generalNo,
content: file.content,
line_count: file.lineCount,
source_bytes: file.sourceBytes,
content_hash: file.contentHash,
import_run_id: importRunId,
updated_at: new Date(),
});
batchBytes += file.sourceBytes;
lines += file.lineCount;
}
await flush();
return { files: loaded.files.length, lines, bytes: loaded.manifest.totalBytes };
};
const saveCheckpoint = async (
client: PoolClient,
profile: LegacyArchiveProfile,
source: BattleResultSourceConfig,
manifest: BattleResultSeasonManifest,
importRunId: string
): Promise<void> => {
await client.query(
`INSERT INTO "legacy_archive"."battle_result_import_checkpoint"
("source_profile", "source_key", "source_fingerprint", "server_id", "manifest_hash",
"file_count", "total_bytes", "import_run_id", "updated_at")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP)
ON CONFLICT ("source_profile", "source_key", "server_id") DO UPDATE SET
"source_fingerprint" = EXCLUDED."source_fingerprint",
"manifest_hash" = EXCLUDED."manifest_hash",
"file_count" = EXCLUDED."file_count",
"total_bytes" = EXCLUDED."total_bytes",
"import_run_id" = EXCLUDED."import_run_id",
"updated_at" = CURRENT_TIMESTAMP`,
[
profile,
source.identity.key,
source.identity.fingerprint,
manifest.serverId,
manifest.manifestHash,
manifest.fileCount,
manifest.totalBytes,
importRunId,
]
);
};
export const migrateBattleResults = async (
targetPool: PgPool,
source: BattleResultSourceConfig,
apply: boolean,
profile: LegacyArchiveProfile,
execution: MigrationExecutionOptions,
prefetchedManifests?: readonly BattleResultSeasonManifest[]
): Promise<BattleResultMigrationSummary> => {
validateSourceIdentity(source.identity);
validateSourceIdentity(execution.source);
if (execution.source.key !== source.identity.key || execution.source.fingerprint !== source.identity.fingerprint) {
throw new Error('Preserved battle-result execution identity does not match its configured source');
}
const manifests = prefetchedManifests ? [...prefetchedManifests] : await listBattleResultSeasons(source, profile);
const counts: BattleResultMigrationSummary['counts'] = {
discoveredSeasons: manifests.length,
discoveredFiles: manifests.reduce((sum, item) => sum + item.fileCount, 0),
discoveredBytes: manifests.reduce((sum, item) => sum + item.totalBytes, 0),
unchangedSeasons: 0,
pendingSeasons: 0,
pendingFiles: 0,
pendingBytes: 0,
importedSeasons: 0,
importedFiles: 0,
importedLines: 0,
importedBytes: 0,
};
const progress: BattleResultMigrationSummary['progress'] = {};
const client = await targetPool.connect();
let importRunId: string | null = null;
try {
const checkpoints = await loadCheckpoints(client, profile, source.identity.key);
const currentServerIds = new Set(manifests.map((manifest) => manifest.serverId));
const missing = [...checkpoints.keys()].filter((serverId) => !currentServerIds.has(serverId));
if (missing.length) {
throw new Error(`Preserved battle-result seasons disappeared from the source: ${missing.join(', ')}`);
}
if (execution.mode === 'incremental') {
if (manifests.length > 0 && checkpoints.size === 0) {
throw new Error('Incremental preserved battle-result migration requires a completed full checkpoint');
}
}
const pending: BattleResultSeasonManifest[] = [];
for (const manifest of manifests) {
const checkpoint = checkpoints.get(manifest.serverId);
if (checkpoint && checkpoint.sourceFingerprint !== source.identity.fingerprint) {
throw new Error(`Preserved battle-result source fingerprint changed for ${manifest.serverId}`);
}
if (checkpoint && sameManifest(checkpoint, manifest)) {
counts.unchangedSeasons += 1;
progress[manifest.serverId] = {
status: 'UNCHANGED',
files: manifest.fileCount,
bytes: manifest.totalBytes,
};
continue;
}
if (checkpoint && execution.mode === 'incremental') {
throw new Error(`Preserved battle-result season changed after checkpoint: ${manifest.serverId}`);
}
pending.push(manifest);
counts.pendingSeasons += 1;
counts.pendingFiles += manifest.fileCount;
counts.pendingBytes += manifest.totalBytes;
progress[manifest.serverId] = { status: 'PENDING', files: manifest.fileCount, bytes: manifest.totalBytes };
}
if (!apply) {
return {
command: 'battle-results',
apply,
mode: execution.mode,
sourceKey: source.identity.key,
importRunId,
counts,
progress,
};
}
await withMigrationLock(
client,
`sammo-legacy-battle-results-v1:${profile}:${source.identity.key}`,
async () => {
const created = await client.query<{ id: string }>(
`INSERT INTO "legacy_archive"."battle_result_import_run"
("source_profile", "source_key", "source_fingerprint", "mode", "status")
VALUES ($1, $2, $3, $4, 'RUNNING') RETURNING "id"`,
[profile, source.identity.key, source.identity.fingerprint, execution.mode]
);
importRunId = created.rows[0]?.id ?? null;
if (!importRunId) throw new Error('Failed to create preserved battle-result import run');
try {
for (const manifest of pending) {
await client.query('BEGIN');
try {
const imported = await upsertBattleResultRows(
client,
profile,
manifest,
source,
importRunId
);
await saveCheckpoint(client, profile, source, manifest, importRunId);
await client.query('COMMIT');
counts.importedSeasons += 1;
counts.importedFiles += imported.files;
counts.importedLines += imported.lines;
counts.importedBytes += imported.bytes;
progress[manifest.serverId] = {
status: 'IMPORTED',
files: imported.files,
bytes: imported.bytes,
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
}
await client.query(
`UPDATE "legacy_archive"."battle_result_import_run"
SET "status" = 'COMPLETED', "finished_at" = CURRENT_TIMESTAMP,
"counts" = $2::jsonb, "progress" = $3::jsonb
WHERE "id" = $1`,
[importRunId, JSON.stringify(counts), JSON.stringify(progress)]
);
} catch (error) {
const message =
error instanceof Error ? error.message.slice(0, 2000) : String(error).slice(0, 2000);
await client.query(
`UPDATE "legacy_archive"."battle_result_import_run"
SET "status" = 'FAILED', "finished_at" = CURRENT_TIMESTAMP,
"counts" = $2::jsonb, "progress" = $3::jsonb, "error" = $4
WHERE "id" = $1`,
[importRunId, JSON.stringify(counts), JSON.stringify(progress), message]
);
throw error;
}
}
);
return {
command: 'battle-results',
apply,
mode: execution.mode,
sourceKey: source.identity.key,
importRunId,
counts,
progress,
};
} finally {
client.release();
}
};
+20 -1
View File
@@ -3,6 +3,7 @@ import { lstat, open } from 'node:fs/promises';
import { isIP } from 'node:net';
import path from 'node:path';
import { resolveBattleResultSourceConfig, type BattleResultSourceConfig } from './battleResultSource.js';
import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from './game.js';
import { fingerprintMariaConnection, type MigrationSourceIdentity } from './incremental.js';
@@ -13,6 +14,7 @@ export interface ResolvedMigrationStage {
sourceUrl: string;
targetUrl: string;
sourceIdentity: MigrationSourceIdentity;
battleResults?: BattleResultSourceConfig;
}
export interface ResolvedMigrationPlan {
@@ -137,7 +139,7 @@ const resolveTargetUrl = (record: Record<string, unknown>, label: string): strin
const parseStage = (value: unknown, label: string): Record<string, unknown> => {
const record = asRecord(value, label);
rejectUnknownKeys(record, ['source', 'targetUrlEnv', 'profile', 'enabled'], label);
rejectUnknownKeys(record, ['source', 'targetUrlEnv', 'profile', 'enabled', 'battleResults'], label);
if (!('source' in record)) throw new Error(`${label}.source is required`);
return record;
};
@@ -192,6 +194,22 @@ export const loadMigrationPlan = async (configPathInput: string): Promise<Resolv
if (seen.has(profile)) throw new Error(`Duplicate profile in migration config: ${profile}`);
seen.add(profile);
const sourceUrl = await resolveSource(profileConfig.source, configDirectory, `${label}.source`);
let battleResults: BattleResultSourceConfig | undefined;
if (profileConfig.battleResults !== undefined) {
const battleResultConfig = asRecord(profileConfig.battleResults, `${label}.battleResults`);
rejectUnknownKeys(battleResultConfig, ['directory', 'sshHost'], `${label}.battleResults`);
const directory = requiredString(battleResultConfig, 'directory', `${label}.battleResults`);
const sshHostValue = battleResultConfig.sshHost;
if (sshHostValue !== undefined && typeof sshHostValue !== 'string') {
throw new Error(`${label}.battleResults.sshHost must be a string`);
}
battleResults = await resolveBattleResultSourceConfig(
{ directory, ...(sshHostValue === undefined ? {} : { sshHost: sshHostValue }) },
`${sourceSet}:${profile}`,
profile,
configDirectory
);
}
profileStages.set(profile, {
kind: 'game',
name: profile,
@@ -202,6 +220,7 @@ export const loadMigrationPlan = async (configPathInput: string): Promise<Resolv
key: `${sourceSet}:${profile}`,
fingerprint: fingerprintMariaConnection(sourceUrl),
},
...(battleResults ? { battleResults } : {}),
});
}
for (const profile of LEGACY_ARCHIVE_PROFILES) {
+111
View File
@@ -0,0 +1,111 @@
import type { ResolvedMigrationStage } from './config.js';
export interface MigrationInventoryItem {
source: string;
target: string;
strategy: 'append' | 'rescan' | 'filesystem-season';
contents: string;
}
export const GATEWAY_MIGRATION_INVENTORY: readonly MigrationInventoryItem[] = [
{
source: 'member',
target: 'app_user + legacy_data',
strategy: 'rescan',
contents: '계정 식별자, 레거시 역할/제재, OAuth 메타데이터와 비밀번호 복구 자료',
},
{
source: 'member_log',
target: 'legacy_member_log',
strategy: 'append',
contents: '계정 변경 감사 기록',
},
{
source: 'banned_member',
target: 'legacy_banned_member',
strategy: 'rescan',
contents: '해시 이메일 차단 기록',
},
{
source: 'storage',
target: 'legacy_root_key_value',
strategy: 'rescan',
contents: 'Gateway 장기 key/value 원문',
},
{
source: 'system',
target: 'system',
strategy: 'rescan',
contents: '가입/로그인 스위치와 공지',
},
];
export const GAME_MIGRATION_INVENTORY: readonly MigrationInventoryItem[] = [
{
source: 'ng_games',
target: 'legacy_archive.game_history',
strategy: 'rescan',
contents: '지난 기수, 시나리오, 개장 시각, 승자와 원본 환경',
},
{
source: 'ng_old_generals',
target: 'legacy_archive.general',
strategy: 'append',
contents: '지난 장수의 능력치, 숙련, 경험, 공헌, 자원, 전투 집계, 특기, 장수 열전과 원본 JSON',
},
{
source: 'logs/preserved/<server_id>/batres<general_no>.txt',
target: 'legacy_archive.general_battle_result',
strategy: 'filesystem-season',
contents: '구형 기수의 장수별 전투 결과 요약; batlog 페이즈 상세는 제외',
},
{
source: 'hall',
target: 'legacy_archive.hall',
strategy: 'append',
contents: '명예의 전당 순위와 점수',
},
{
source: 'ng_old_nations',
target: 'legacy_archive.nation',
strategy: 'append',
contents: '지난 국가 구성, 장수 목록과 국가 연혁',
},
{
source: 'emperior',
target: 'legacy_archive.emperor',
strategy: 'append',
contents: '왕조 일람, 통일 국가/황제/관직/국력/연혁',
},
{
source: 'inheritance_result',
target: 'inheritance_result',
strategy: 'append',
contents: '유산 결과 원문과 점수',
},
{
source: 'user_record',
target: 'inheritance_log',
strategy: 'append',
contents: '사용자별 유산 획득/사용 장기 기록',
},
{
source: 'storage:inheritance_* / user_*',
target: 'legacy_game_storage + inheritance_point + inheritance_user_state',
strategy: 'rescan',
contents: '유산 포인트와 사용자 유산 상태 및 원본 tuple',
},
{
source: 'ng_history',
target: 'legacy_archive.yearbook',
strategy: 'append',
contents: '월별 지도, 국가, 천하 동향과 전체 기록 연감',
},
];
export const migrationInventoryForStage = (stage: ResolvedMigrationStage): readonly MigrationInventoryItem[] =>
stage.kind === 'gateway'
? GATEWAY_MIGRATION_INVENTORY
: GAME_MIGRATION_INVENTORY.filter(
(item) => item.strategy !== 'filesystem-season' || stage.battleResults !== undefined
);
+72 -6
View File
@@ -1,18 +1,31 @@
import { listBattleResultSeasons, type BattleResultSeasonManifest } from './battleResultSource.js';
import { migrateBattleResults, type BattleResultMigrationSummary } from './battleResults.js';
import { createMariaPool, createPostgresPool, querySource } from './db.js';
import { migrateGame } from './game.js';
import { migrateGateway, type MigrationSummary } from './gateway.js';
import type { MigrationMode } from './incremental.js';
import type { ResolvedMigrationPlan, ResolvedMigrationStage } from './config.js';
import { migrationInventoryForStage } from './inventory.js';
export interface PlanRunSummary {
command: 'run-plan';
sourceSet: string;
mode: MigrationMode;
apply: boolean;
stages: Array<{ name: string; status: 'COMPLETED'; summary: MigrationSummary }>;
stages: Array<{
name: string;
status: 'COMPLETED';
summary: MigrationSummary;
battleResults?: BattleResultMigrationSummary;
}>;
}
const preflightStage = async (stage: ResolvedMigrationStage): Promise<void> => {
interface StagePreflight {
battleResults?: { seasons: number; files: number; bytes: number };
battleResultManifests?: readonly BattleResultSeasonManifest[];
}
const preflightStage = async (stage: ResolvedMigrationStage): Promise<StagePreflight> => {
const source = createMariaPool(stage.sourceUrl);
const target = createPostgresPool(stage.targetUrl);
try {
@@ -63,6 +76,27 @@ const preflightStage = async (stage: ResolvedMigrationStage): Promise<void> => {
if (!targetReady.rows[0]?.table_name) {
throw new Error(`Target migrations are not current for ${stage.name}; missing ${targetDataTable}`);
}
if (stage.kind === 'game' && stage.battleResults) {
const battleResultReady = await target.query<{ table_name: string | null }>(
'SELECT to_regclass($1) AS table_name',
['legacy_archive.general_battle_result']
);
if (!battleResultReady.rows[0]?.table_name) {
throw new Error(
`Target migrations are not current for ${stage.name}; missing legacy_archive.general_battle_result`
);
}
const seasons = await listBattleResultSeasons(stage.battleResults, stage.profile!);
return {
battleResults: {
seasons: seasons.length,
files: seasons.reduce((sum, season) => sum + season.fileCount, 0),
bytes: seasons.reduce((sum, season) => sum + season.totalBytes, 0),
},
battleResultManifests: seasons,
};
}
return {};
} finally {
await source.end();
await target.end();
@@ -70,11 +104,21 @@ const preflightStage = async (stage: ResolvedMigrationStage): Promise<void> => {
};
export const checkMigrationPlan = async (plan: ResolvedMigrationPlan): Promise<Record<string, unknown>> => {
for (const stage of plan.stages) await preflightStage(stage);
const stages = [];
for (const stage of plan.stages) {
const preflight = await preflightStage(stage);
stages.push({
name: stage.name,
kind: stage.kind,
status: 'READY',
inventory: migrationInventoryForStage(stage),
...(preflight.battleResults ? { battleResults: preflight.battleResults } : {}),
});
}
return {
command: 'check-plan',
sourceSet: plan.sourceSet,
stages: plan.stages.map((stage) => ({ name: stage.name, kind: stage.kind, status: 'READY' })),
stages,
};
};
@@ -84,7 +128,10 @@ export const runMigrationPlan = async (
apply: boolean,
migratedAt = new Date()
): Promise<PlanRunSummary> => {
await checkMigrationPlan(plan);
const preflights = new Map<string, StagePreflight>();
for (const stage of plan.stages) {
preflights.set(stage.name, await preflightStage(stage));
}
const stages: PlanRunSummary['stages'] = [];
for (const stage of plan.stages) {
const source = createMariaPool(stage.sourceUrl);
@@ -95,7 +142,26 @@ export const runMigrationPlan = async (
stage.kind === 'gateway'
? await migrateGateway(source, target, apply, migratedAt, execution)
: await migrateGame(source, target, apply, stage.profile!, execution);
stages.push({ name: stage.name, status: 'COMPLETED', summary });
const battleResults =
stage.kind === 'game' && stage.battleResults
? await migrateBattleResults(
target,
stage.battleResults,
apply,
stage.profile!,
{
mode,
source: stage.battleResults.identity,
},
preflights.get(stage.name)?.battleResultManifests
)
: undefined;
stages.push({
name: stage.name,
status: 'COMPLETED',
summary,
...(battleResults ? { battleResults } : {}),
});
} finally {
await source.end();
await target.end();