merge: 과거 장수 전투 결과 보존 이관 반영

This commit is contained in:
2026-08-18 13:17:17 +00:00
20 changed files with 1359 additions and 51 deletions
+17 -2
View File
@@ -18,6 +18,7 @@ import {
import { import {
findLegacyEmperors, findLegacyEmperors,
findLegacyGeneral, findLegacyGeneral,
findLegacyGeneralBattleResult,
findLegacyGeneralsByOwner, findLegacyGeneralsByOwner,
findLegacyGames, findLegacyGames,
findLegacyNations, findLegacyNations,
@@ -412,6 +413,8 @@ export const archiveRouter = router({
let entry: GeneralArchiveEntry | null = null; let entry: GeneralArchiveEntry | null = null;
let nationRows: ArchiveNationEntry[] = []; let nationRows: ArchiveNationEntry[] = [];
let dynastyId: number | null = null; let dynastyId: number | null = null;
let battleResultContent: string | null = null;
let battleResultAvailable = false;
if (input.source === 'legacy') { if (input.source === 'legacy') {
if (!LEGACY_ARCHIVE_PROFILES.includes(sourceProfile as LegacyArchiveProfile)) { if (!LEGACY_ARCHIVE_PROFILES.includes(sourceProfile as LegacyArchiveProfile)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이전 서버 프로필입니다.' }); throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이전 서버 프로필입니다.' });
@@ -435,9 +438,14 @@ export const archiveRouter = router({
snapshot: canonicalSnapshot(row.data, row.name), snapshot: canonicalSnapshot(row.data, row.name),
}; };
const keyInput = [{ sourceProfile: profile, serverId: input.serverId }]; const keyInput = [{ sourceProfile: profile, serverId: input.serverId }];
const [nations, emperors] = await Promise.all([ const [nations, emperors, battleResult] = await Promise.all([
findLegacyNations(ctx.db, keyInput), findLegacyNations(ctx.db, keyInput),
findLegacyEmperors(ctx.db, keyInput), findLegacyEmperors(ctx.db, keyInput),
findLegacyGeneralBattleResult(ctx.db, {
sourceProfile: profile,
serverId: input.serverId,
generalNo: input.generalNo,
}),
]); ]);
nationRows = nations.map((nation) => ({ nationRows = nations.map((nation) => ({
source: 'legacy', source: 'legacy',
@@ -448,6 +456,8 @@ export const archiveRouter = router({
data: asRecord(nation.data), data: asRecord(nation.data),
})); }));
dynastyId = Number(emperors[0]?.id ?? 0) || null; dynastyId = Number(emperors[0]?.id ?? 0) || null;
battleResultContent = battleResult?.content ?? null;
battleResultAvailable = battleResult !== null;
} }
} else { } else {
const row = await ctx.db.oldGeneral.findFirst({ const row = await ctx.db.oldGeneral.findFirst({
@@ -494,13 +504,18 @@ export const archiveRouter = router({
const nation = resolveNation(nationMap, entry); const nation = resolveNation(nationMap, entry);
const snapshot = entry.snapshot; const snapshot = entry.snapshot;
const general = await buildGeneralDetail(entry, nation); const general = await buildGeneralDetail(entry, nation);
const battleResultEntries = (battleResultContent ?? '')
.split(/\r?\n/u)
.map((text, index) => ({ id: index + 1, text }))
.filter((item) => item.text.length > 0)
.reverse();
const logs = { const logs = {
generalHistory: { generalHistory: {
available: snapshot.availability.history, available: snapshot.availability.history,
entries: snapshot.history.map((text, index) => ({ id: index + 1, text })), entries: snapshot.history.map((text, index) => ({ id: index + 1, text })),
}, },
battleDetail: { available: snapshot.availability.battleDetailLogs, entries: [] }, battleDetail: { available: snapshot.availability.battleDetailLogs, entries: [] },
battleResult: { available: snapshot.availability.battleResultLogs, entries: [] }, battleResult: { available: battleResultAvailable, entries: battleResultEntries },
generalAction: { available: false, entries: [] }, generalAction: { available: false, entries: [] },
}; };
return { return {
@@ -36,6 +36,12 @@ export interface LegacyGeneralRow {
data: unknown; data: unknown;
} }
export interface LegacyGeneralBattleResultRow {
content: string;
lineCount: number;
contentHash: string;
}
export interface LegacyNationRow { export interface LegacyNationRow {
sourceProfile: LegacyArchiveProfile; sourceProfile: LegacyArchiveProfile;
legacyId: number; legacyId: number;
@@ -120,6 +126,24 @@ export const findLegacyGeneral = async (
return rows[0] ?? null; return rows[0] ?? null;
}; };
export const findLegacyGeneralBattleResult = async (
db: LegacyArchiveDatabase,
input: { sourceProfile: LegacyArchiveProfile; serverId: string; generalNo: number }
): Promise<LegacyGeneralBattleResultRow | null> => {
const rows = await db.$queryRaw<LegacyGeneralBattleResultRow[]>(GamePrisma.sql`
SELECT
"content",
"line_count" AS "lineCount",
"content_hash" AS "contentHash"
FROM "legacy_archive"."general_battle_result"
WHERE "source_profile" = ${input.sourceProfile}
AND "server_id" = ${input.serverId}
AND "general_no" = ${input.generalNo}
LIMIT 1
`);
return rows[0] ?? null;
};
export const findLegacyGeneralsForServer = async ( export const findLegacyGeneralsForServer = async (
db: LegacyArchiveDatabase, db: LegacyArchiveDatabase,
input: { sourceProfile: LegacyArchiveProfile; serverId: string; generalNos: number[] } input: { sourceProfile: LegacyArchiveProfile; serverId: string; generalNos: number[] }
+16
View File
@@ -30,6 +30,15 @@ const context = (session: GameSessionTokenPayload | null, includeLegacy = false)
$queryRaw: async (query: { strings?: readonly string[] }) => { $queryRaw: async (query: { strings?: readonly string[] }) => {
if (!includeLegacy) return []; if (!includeLegacy) return [];
const sql = query.strings?.join(' ') ?? ''; const sql = query.strings?.join(' ') ?? '';
if (sql.includes('legacy_archive"."general_battle_result')) {
return [
{
content: '<S>◆</>190년 1월:첫 전투\n<S>◆</>190년 2월:둘째 전투\n',
lineCount: 2,
contentHash: 'a'.repeat(64),
},
];
}
if (sql.includes('legacy_archive"."general')) { if (sql.includes('legacy_archive"."general')) {
return [ return [
{ {
@@ -385,6 +394,13 @@ describe('archive.myPastPlays', () => {
logs: expect.objectContaining({ logs: expect.objectContaining({
generalHistory: { available: true, entries: [{ id: 1, text: '이전 서버 열전' }] }, generalHistory: { available: true, entries: [{ id: 1, text: '이전 서버 열전' }] },
battleDetail: { available: false, entries: [] }, battleDetail: { available: false, entries: [] },
battleResult: {
available: true,
entries: [
{ id: 2, text: '<S>◆</>190년 2월:둘째 전투' },
{ id: 1, text: '<S>◆</>190년 1월:첫 전투' },
],
},
}), }),
}); });
expect(JSON.stringify(detail)).not.toContain('raw_data'); expect(JSON.stringify(detail)).not.toContain('raw_data');
+12 -4
View File
@@ -115,7 +115,16 @@ const installArchive = async (page: Page, options: { battleAvailable?: boolean }
], ],
}, },
battleDetail: { available: false, entries: [] }, battleDetail: { available: false, entries: [] },
battleResult: { available: false, entries: [] }, battleResult: {
available: true,
entries: [
{
id: 2,
text: '<S>◆</>214년 3월:<div class="small_war_log">관우 7000 ← 장비 0</div>',
},
{ id: 1, text: '<S>◆</>214년 2월:관우 6500 → 여포 0' },
],
},
generalAction: { available: false, entries: [] }, generalAction: { available: false, entries: [] },
}, },
}); });
@@ -177,9 +186,8 @@ test('past plays is available without a current general and preserves desktop in
await expect(page.locator('[data-log-type="battleDetail"]')).toContainText( await expect(page.locator('[data-log-type="battleDetail"]')).toContainText(
'이 기수에는 전투 기록이 보존되지 않았습니다.' '이 기수에는 전투 기록이 보존되지 않았습니다.'
); );
await expect(page.locator('[data-log-type="battleResult"]')).toContainText( await expect(page.locator('[data-log-type="battleResult"]')).toContainText('214년 3월:관우 7000 ← 장비 0');
'이 기수에는 전투 결과가 보존되지 않았습니다.' await expect(page.locator('[data-log-type="battleResult"]')).not.toContainText('<div');
);
await expect(page.locator('[data-log-type="generalAction"]')).toContainText( await expect(page.locator('[data-log-type="generalAction"]')).toContainText(
'이 기수에는 개인 기록이 보존되지 않았습니다.' '이 기수에는 개인 기록이 보존되지 않았습니다.'
); );
+1 -1
View File
@@ -39,7 +39,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260818000000_add_legacy_import_checkpoints', gatewaySchemaHead: '20260818000000_add_legacy_import_checkpoints',
gameSchemaHead: '20260818000000_add_legacy_import_checkpoints', gameSchemaHead: '20260818010000_add_legacy_battle_result_logs',
}); });
}); });
+31 -11
View File
@@ -27,6 +27,10 @@ checks that the checkpoint migrations exist. Only after all stages pass does it
run Gateway, then `che,kwe,pwe,twe,nya,pya,hwe` in that order, skipping disabled run Gateway, then `che,kwe,pwe,twe,nya,pya,hwe` in that order, skipping disabled
profiles. Dry-run and apply use the same transformations and return per-table profiles. Dry-run and apply use the same transformations and return per-table
`progress` with strategy, start cursor, end cursor and processed count. `progress` with strategy, start cursor, end cursor and processed count.
`check-plan` additionally returns an `inventory` array for every stage. Each
entry names the source, target, full/incremental strategy and the user-visible or
archival information transferred, so the reviewed plan itself is the migration
manifest rather than an implicit table list.
The initial apply uses `--mode full`. A later `--mode incremental` requires the The initial apply uses `--mode full`. A later `--mode incremental` requires the
same `sourceSet` and a completed checkpoint for every append-only table. same `sourceSet` and a completed checkpoint for every append-only table.
@@ -93,10 +97,11 @@ The password is never accepted as an argument or printed.
### Game profiles ### Game profiles
| Legacy table | Dedicated target | Policy | | Legacy table | Dedicated target | Policy |
| ------------------------------- | ----------------------------- | --------------------------------------------------------------------- | | ---------------------------------- | -------------------------------------- | -------------------------------------------------------------------------- |
| `ng_games` | `legacy_archive.game_history` | Preserve source profile, opening date, scenario and raw environment | | `ng_games` | `legacy_archive.game_history` | Preserve source profile, opening date, scenario and raw environment |
| `hall` | `legacy_archive.hall` | Preserve hall-of-fame rows without mixing current records | | `hall` | `legacy_archive.hall` | Preserve hall-of-fame rows without mixing current records |
| `ng_old_generals` | `legacy_archive.general` | Preserve canonical V1 plus private raw JSON and owner | | `ng_old_generals` | `legacy_archive.general` | Preserve canonical V1 plus private raw JSON and owner |
| preserved `batres<general_no>.txt` | `legacy_archive.general_battle_result` | Preserve exact old per-general battle-result summaries; exclude phase logs |
| `ng_old_nations` | `legacy_archive.nation` | Preserve all versions with profile and legacy primary key | | `ng_old_nations` | `legacy_archive.nation` | Preserve all versions with profile and legacy primary key |
| `emperior` | `legacy_archive.emperor` | Preserve dynasty detail under a central archive ID | | `emperior` | `legacy_archive.emperor` | Preserve dynasty detail under a central archive ID |
| `inheritance_result` | `inheritance_result` | Preserve result JSON/string and legacy key | | `inheritance_result` | `inheritance_result` | Preserve result JSON/string and legacy key |
@@ -124,16 +129,31 @@ shape. Missing battle aggregates and logs are `null` plus explicit
`availability`, never fabricated zeroes. The source JSON remains in `availability`, never fabricated zeroes. The source JSON remains in
`legacy_archive.general.raw_data` for recovery, but no API returns it. `legacy_archive.general.raw_data` for recovery, but no API returns it.
Ref also retains per-season filesystem trees under `logs/preserved`. They are Ref also retains heterogeneous per-season filesystem trees under
not a MariaDB source: season reset moves the whole prior log directory there, `logs/preserved`. When a profile plan supplies `battleResults.directory` and,
and the tree mixes legacy `gen*`, `batlog*`, `batres*`, tournament `fight*`, optionally, `sshHost`, the importer selects only immediate regular files matching
SQLite API logs, and administrative/operational logs. The long-lived importer `<profile>_*/batres<general_no>.txt`. The filename and directory provide the
therefore never reads a local or remote filesystem path. The required condensed same `(source_profile, server_id, general_no)` key as the archived general.
general history is already embedded by Ref in `ng_old_generals.data.history`, Exact UTF-8 text, line count, source bytes and SHA-256 are stored; the archive
and yearbook history comes from `ng_history`. Importing the other file artifacts API returns the lines newest-first like Ref and the frontend renders stripped
would require a separately reviewed archive format, ownership mapping, privacy plain text, never trusted archived HTML.
policy, and storage budget; their absence is reported as not preserved rather
than guessed from filenames. Every season gets a content manifest and checkpoint. Full apply can add or
atomically replace a changed season and is resumable at season boundaries.
Incremental apply requires a prior full checkpoint, accepts only new immutable
seasons and rejects a changed checkpointed season or changed filesystem source
identity. Both modes reject a disappeared checkpointed season instead of
silently retaining stale data.
`check-plan` reports season/file/byte totals without returning log contents.
The importer deliberately excludes `batlog*` phase-by-phase battle detail,
`gen*` action logs, tournament `fight*`, SQLite API logs and administrative or
operational files. The production preserved trees contain battle-result files
only for the older filesystem-log era (observed seasons end around May 2020),
while later reset-time `general_record` rows were not retained in these trees.
Consequently this feature recovers meaningful old `batres` summaries but cannot
claim complete battle-result coverage for every historical season. Missing data
stays explicitly unavailable.
The source contains legitimate duplicate `(server_id, nation)` old-nation rows The source contains legitimate duplicate `(server_id, nation)` old-nation rows
and `(server_id, year, month)` history rows. `source_id` is consequently part of and `(server_id, year, month)` history rows. `source_id` is consequently part of
@@ -0,0 +1,63 @@
CREATE TABLE "legacy_archive"."battle_result_import_run" (
"id" BIGSERIAL PRIMARY KEY,
"source_profile" TEXT NOT NULL,
"source_key" TEXT NOT NULL,
"source_fingerprint" CHAR(64) NOT NULL,
"mode" TEXT NOT NULL,
"status" TEXT NOT NULL,
"started_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finished_at" TIMESTAMPTZ,
"counts" JSONB NOT NULL DEFAULT '{}'::jsonb,
"progress" JSONB NOT NULL DEFAULT '{}'::jsonb,
"error" TEXT,
CONSTRAINT "legacy_archive_battle_result_run_profile_check"
CHECK ("source_profile" IN ('che', 'kwe', 'pwe', 'twe', 'nya', 'pya', 'hwe')),
CONSTRAINT "legacy_archive_battle_result_run_mode_check" CHECK ("mode" IN ('full', 'incremental')),
CONSTRAINT "legacy_archive_battle_result_run_status_check"
CHECK ("status" IN ('RUNNING', 'COMPLETED', 'FAILED')),
CONSTRAINT "legacy_archive_battle_result_run_fingerprint_check"
CHECK ("source_fingerprint" ~ '^[a-f0-9]{64}$')
);
CREATE INDEX "legacy_archive_battle_result_run_source_started"
ON "legacy_archive"."battle_result_import_run" ("source_profile", "source_key", "started_at" DESC);
CREATE TABLE "legacy_archive"."general_battle_result" (
"source_profile" TEXT NOT NULL,
"server_id" TEXT NOT NULL,
"general_no" INTEGER NOT NULL,
"content" TEXT NOT NULL,
"line_count" INTEGER NOT NULL,
"source_bytes" BIGINT NOT NULL,
"content_hash" CHAR(64) NOT NULL,
"import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."battle_result_import_run" ("id"),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("source_profile", "server_id", "general_no"),
CONSTRAINT "legacy_archive_general_battle_result_profile_check"
CHECK ("source_profile" IN ('che', 'kwe', 'pwe', 'twe', 'nya', 'pya', 'hwe')),
CONSTRAINT "legacy_archive_general_battle_result_general_no_check" CHECK ("general_no" >= 0),
CONSTRAINT "legacy_archive_general_battle_result_line_count_check" CHECK ("line_count" >= 0),
CONSTRAINT "legacy_archive_general_battle_result_source_bytes_check" CHECK ("source_bytes" >= 0),
CONSTRAINT "legacy_archive_general_battle_result_hash_check" CHECK ("content_hash" ~ '^[a-f0-9]{64}$')
);
CREATE TABLE "legacy_archive"."battle_result_import_checkpoint" (
"source_profile" TEXT NOT NULL,
"source_key" TEXT NOT NULL,
"source_fingerprint" CHAR(64) NOT NULL,
"server_id" TEXT NOT NULL,
"manifest_hash" CHAR(64) NOT NULL,
"file_count" INTEGER NOT NULL,
"total_bytes" BIGINT NOT NULL,
"import_run_id" BIGINT NOT NULL REFERENCES "legacy_archive"."battle_result_import_run" ("id"),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("source_profile", "source_key", "server_id"),
CONSTRAINT "legacy_archive_battle_result_checkpoint_profile_check"
CHECK ("source_profile" IN ('che', 'kwe', 'pwe', 'twe', 'nya', 'pya', 'hwe')),
CONSTRAINT "legacy_archive_battle_result_checkpoint_fingerprint_check"
CHECK ("source_fingerprint" ~ '^[a-f0-9]{64}$'),
CONSTRAINT "legacy_archive_battle_result_checkpoint_manifest_check"
CHECK ("manifest_hash" ~ '^[a-f0-9]{64}$'),
CONSTRAINT "legacy_archive_battle_result_checkpoint_file_count_check" CHECK ("file_count" >= 0),
CONSTRAINT "legacy_archive_battle_result_checkpoint_total_bytes_check" CHECK ("total_bytes" >= 0)
);
+1 -1
View File
@@ -2,6 +2,6 @@
"formatVersion": 1, "formatVersion": 1,
"controllerProtocol": 2, "controllerProtocol": 2,
"gatewaySchemaHead": "20260818000000_add_legacy_import_checkpoints", "gatewaySchemaHead": "20260818000000_add_legacy_import_checkpoints",
"gameSchemaHead": "20260818000000_add_legacy_import_checkpoints", "gameSchemaHead": "20260818010000_add_legacy_battle_result_logs",
"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"]
} }
+29 -8
View File
@@ -55,7 +55,22 @@ pnpm migrate:legacy -- run-plan \
--config tools/legacy-db-migration/migration-plan.json --mode full --apply --config tools/legacy-db-migration/migration-plan.json --mode full --apply
``` ```
`check-plan` opens every source and target without writing. `run-plan` also For profiles whose old file archive is available, add a `battleResults` block.
`directory` may be a local absolute/plan-relative path, or `sshHost` may select
the host from which the directory is read. The SSH target must be a configured
host alias; do not put credentials in the plan.
```json
"battleResults": {
"sshHost": "serv",
"directory": "/home/letrhee/web_symlinks/sam_hided_net/sam/che/logs/preserved"
}
```
`check-plan` opens every source and target without writing. Its stage JSON lists
every included item as `inventory`, including source, target, strategy and the
information transferred. A configured battle-result source also reports its
season/file/byte counts. `run-plan` also
preflights every stage before the first import, is a dry-run without `--apply`, preflights every stage before the first import, is a dry-run without `--apply`,
and stops at the first failed stage. Completed earlier stages remain committed; and stops at the first failed stage. Completed earlier stages remain committed;
rerunning is safe because the Gateway and each profile have independent locks, rerunning is safe because the Gateway and each profile have independent locks,
@@ -78,23 +93,29 @@ whose maximum ID moved behind its checkpoint. Password rotation does not change
the source fingerprint. the source fingerprint.
| Source data | Incremental policy | | Source data | Incremental policy |
| --------------------------------------------- | --------------------------------------------------------------------- | | --------------------------------------------- | ----------------------------------------------------------------------- |
| `member_log` | Read only IDs after the committed high-water mark. | | `member_log` | Read only IDs after the committed high-water mark. |
| game archive/event-history tables | Read only IDs after the profile checkpoint. | | game archive/event-history tables | Read only IDs after the profile checkpoint. |
| `member`, root/game `storage`, `system`, bans | Rescan and idempotently upsert because old rows are mutable. | | `member`, root/game `storage`, `system`, bans | Rescan and idempotently upsert because old rows are mutable. |
| `ng_games` | Rescan because a season row can gain its final winner after creation. | | `ng_games` | Rescan because a season row can gain its final winner after creation. |
| preserved `batres<general_no>.txt` seasons | Hash each season; import new immutable seasons after a full checkpoint. |
The append policy assumes Ref primary keys are never reused and completed The append policy assumes Ref primary keys are never reused and completed
archive rows are immutable. Incremental mode does not mirror source deletions. archive rows are immutable. Incremental mode does not mirror source deletions.
If either assumption is false, take a new reviewed backup and run full mode; If either assumption is false, take a new reviewed backup and run full mode;
do not edit checkpoint rows by hand. do not edit checkpoint rows by hand.
The command reads MariaDB only. Ref `logs/preserved/<season>/` directories are The optional file importer reads only immediate
heterogeneous filesystem archives (old general/battle text, tournament text, `logs/preserved/<profile>_*/batres<general_no>.txt` regular files. It maps the
SQLite API logs, and operational logs), not an incremental database feed. profile and season directory to the archived general's `(source_profile,
`ng_old_generals.data.history` and `ng_history` carry the supported long-lived server_id, general_no)` key, verifies file and season SHA-256 hashes, and stores
history. Do not point this CLI at, copy, or infer database rows from preserved the exact text plus line/byte counts. It never follows symlinks and ignores
log files; a file-log archive needs a separate reviewed migration contract. `batlog*` phase detail, `gen*`, `fight*`, SQLite and operational logs. Full mode
creates the season checkpoints. Incremental mode accepts new seasons but rejects
a changed checkpointed season or changed source identity. Every mode rejects a
disappeared checkpointed season. A reviewed full run atomically replaces a
changed season's archive rows. The preserved trees currently cover only the
old filesystem-log era, so an absent file remains explicitly unavailable.
## Commands ## Commands
@@ -23,6 +23,10 @@
"passwordFile": "./secrets/mysql-che-password", "passwordFile": "./secrets/mysql-che-password",
"tls": true "tls": true
}, },
"battleResults": {
"sshHost": "serv",
"directory": "/home/letrhee/web_symlinks/sam_hided_net/sam/che/logs/preserved"
},
"targetUrlEnv": "CHE_GAME_DATABASE_URL" "targetUrlEnv": "CHE_GAME_DATABASE_URL"
}, },
{ {
@@ -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 { isIP } from 'node:net';
import path from 'node:path'; import path from 'node:path';
import { resolveBattleResultSourceConfig, type BattleResultSourceConfig } from './battleResultSource.js';
import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from './game.js'; import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from './game.js';
import { fingerprintMariaConnection, type MigrationSourceIdentity } from './incremental.js'; import { fingerprintMariaConnection, type MigrationSourceIdentity } from './incremental.js';
@@ -13,6 +14,7 @@ export interface ResolvedMigrationStage {
sourceUrl: string; sourceUrl: string;
targetUrl: string; targetUrl: string;
sourceIdentity: MigrationSourceIdentity; sourceIdentity: MigrationSourceIdentity;
battleResults?: BattleResultSourceConfig;
} }
export interface ResolvedMigrationPlan { 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 parseStage = (value: unknown, label: string): Record<string, unknown> => {
const record = asRecord(value, label); 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`); if (!('source' in record)) throw new Error(`${label}.source is required`);
return record; 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}`); if (seen.has(profile)) throw new Error(`Duplicate profile in migration config: ${profile}`);
seen.add(profile); seen.add(profile);
const sourceUrl = await resolveSource(profileConfig.source, configDirectory, `${label}.source`); 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, { profileStages.set(profile, {
kind: 'game', kind: 'game',
name: profile, name: profile,
@@ -202,6 +220,7 @@ export const loadMigrationPlan = async (configPathInput: string): Promise<Resolv
key: `${sourceSet}:${profile}`, key: `${sourceSet}:${profile}`,
fingerprint: fingerprintMariaConnection(sourceUrl), fingerprint: fingerprintMariaConnection(sourceUrl),
}, },
...(battleResults ? { battleResults } : {}),
}); });
} }
for (const profile of LEGACY_ARCHIVE_PROFILES) { 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 { createMariaPool, createPostgresPool, querySource } from './db.js';
import { migrateGame } from './game.js'; import { migrateGame } from './game.js';
import { migrateGateway, type MigrationSummary } from './gateway.js'; import { migrateGateway, type MigrationSummary } from './gateway.js';
import type { MigrationMode } from './incremental.js'; import type { MigrationMode } from './incremental.js';
import type { ResolvedMigrationPlan, ResolvedMigrationStage } from './config.js'; import type { ResolvedMigrationPlan, ResolvedMigrationStage } from './config.js';
import { migrationInventoryForStage } from './inventory.js';
export interface PlanRunSummary { export interface PlanRunSummary {
command: 'run-plan'; command: 'run-plan';
sourceSet: string; sourceSet: string;
mode: MigrationMode; mode: MigrationMode;
apply: boolean; 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 source = createMariaPool(stage.sourceUrl);
const target = createPostgresPool(stage.targetUrl); const target = createPostgresPool(stage.targetUrl);
try { try {
@@ -63,6 +76,27 @@ const preflightStage = async (stage: ResolvedMigrationStage): Promise<void> => {
if (!targetReady.rows[0]?.table_name) { if (!targetReady.rows[0]?.table_name) {
throw new Error(`Target migrations are not current for ${stage.name}; missing ${targetDataTable}`); 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 { } finally {
await source.end(); await source.end();
await target.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>> => { 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 { return {
command: 'check-plan', command: 'check-plan',
sourceSet: plan.sourceSet, 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, apply: boolean,
migratedAt = new Date() migratedAt = new Date()
): Promise<PlanRunSummary> => { ): 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'] = []; const stages: PlanRunSummary['stages'] = [];
for (const stage of plan.stages) { for (const stage of plan.stages) {
const source = createMariaPool(stage.sourceUrl); const source = createMariaPool(stage.sourceUrl);
@@ -95,7 +142,26 @@ export const runMigrationPlan = async (
stage.kind === 'gateway' stage.kind === 'gateway'
? await migrateGateway(source, target, apply, migratedAt, execution) ? await migrateGateway(source, target, apply, migratedAt, execution)
: await migrateGame(source, target, apply, stage.profile!, 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 { } finally {
await source.end(); await source.end();
await target.end(); await target.end();
@@ -0,0 +1,69 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
listBattleResultSeasons,
readBattleResultSeason,
resolveBattleResultSourceConfig,
} from '../src/battleResultSource.js';
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))
);
});
const fixture = async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'sammo-battle-results-'));
temporaryDirectories.push(root);
const season = path.join(root, 'che_190815_w0sU');
await mkdir(season);
await writeFile(path.join(season, 'batres17.txt'), '<S>◆</>190년 1월:첫 전투\n<S>◆</>190년 2월:둘째 전투\n');
await writeFile(path.join(season, 'batlog17.txt'), '페이즈 상세는 제외\n');
await mkdir(path.join(root, 'not-a-season'));
return { root, season };
};
describe('preserved battle-result source', () => {
it('maps only batres files by profile/server/general and preserves source text', async () => {
const { root } = await fixture();
const source = await resolveBattleResultSourceConfig({ directory: root }, 'fixture:che', 'che', process.cwd());
const seasons = await listBattleResultSeasons(source, 'che');
expect(seasons).toHaveLength(1);
expect(seasons[0]).toMatchObject({ serverId: 'che_190815_w0sU', fileCount: 1 });
expect(seasons[0]?.files[0]).toMatchObject({ generalNo: 17 });
const loaded = await readBattleResultSeason(source, 'che', 'che_190815_w0sU');
expect(loaded.manifest.manifestHash).toBe(seasons[0]?.manifestHash);
expect(loaded.files).toHaveLength(1);
expect(loaded.files[0]).toMatchObject({ generalNo: 17, lineCount: 2 });
expect(loaded.files[0]?.content).toContain('둘째 전투');
});
it('builds a password-free source identity for the configured SSH location', async () => {
const source = await resolveBattleResultSourceConfig(
{ directory: '/srv/sammo/che/logs/preserved', sshHost: 'serv' },
'cutover:che',
'che',
process.cwd()
);
expect(source).toMatchObject({ kind: 'ssh', sshHost: 'serv' });
expect(source.identity.key).toBe('cutover:che:battle-results');
expect(source.identity.fingerprint).toMatch(/^[a-f0-9]{64}$/u);
});
it('rejects invalid UTF-8 during preflight instead of failing after target writes begin', async () => {
const { root, season } = await fixture();
await writeFile(path.join(season, 'batres18.txt'), Buffer.from([0xff]));
const source = await resolveBattleResultSourceConfig({ directory: root }, 'fixture:che', 'che', process.cwd());
await expect(listBattleResultSeasons(source, 'che')).rejects.toThrow();
});
});
@@ -0,0 +1,177 @@
import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { Pool as PgPool, PoolClient, QueryResult } from 'pg';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { listBattleResultSeasons, type BattleResultSourceConfig } from '../src/battleResultSource.js';
import { migrateBattleResults } from '../src/battleResults.js';
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))
);
});
const sourceFixture = async (): Promise<BattleResultSourceConfig> => {
const root = await mkdtemp(path.join(os.tmpdir(), 'sammo-battle-results-migrate-'));
temporaryDirectories.push(root);
const season = path.join(root, 'che_190815_w0sU');
await mkdir(season);
await writeFile(path.join(season, 'batres17.txt'), '첫 전투\n둘째 전투\n');
return {
kind: 'local',
directory: root,
identity: { key: 'fixture:che:battle-results', fingerprint: 'b'.repeat(64) },
};
};
const targetPool = (
checkpointRows: Array<Record<string, unknown>> = [],
failPattern?: string
): { pool: PgPool; queries: Array<{ sql: string; values: readonly unknown[] }> } => {
const queries: Array<{ sql: string; values: readonly unknown[] }> = [];
const query = vi.fn(async (sql: string, values: readonly unknown[] = []) => {
queries.push({ sql, values });
if (failPattern && sql.includes(failPattern)) {
failPattern = undefined;
throw new Error('synthetic battle-result write failure');
}
if (sql.includes('FROM "legacy_archive"."battle_result_import_checkpoint"')) {
return { rows: checkpointRows, rowCount: checkpointRows.length } as unknown as QueryResult;
}
if (sql.includes('INSERT INTO "legacy_archive"."battle_result_import_run"')) {
return { rows: [{ id: '91' }], rowCount: 1 } as QueryResult<{ id: string }>;
}
return { rows: [], rowCount: 0 } as unknown as QueryResult;
});
const client = { query, release: vi.fn() } as unknown as PoolClient;
return { pool: { connect: vi.fn(async () => client) } as unknown as PgPool, queries };
};
describe('preserved battle-result migration', () => {
it('rejects an execution identity different from the configured archive source', async () => {
const source = await sourceFixture();
await expect(
migrateBattleResults(targetPool().pool, source, false, 'che', {
mode: 'full',
source: { key: 'different:source', fingerprint: 'c'.repeat(64) },
})
).rejects.toThrow('execution identity does not match');
});
it('imports one immutable season transactionally and checkpoints it', async () => {
const source = await sourceFixture();
const target = targetPool();
const summary = await migrateBattleResults(target.pool, source, true, 'che', {
mode: 'full',
source: source.identity,
});
const sql = target.queries.map((entry) => entry.sql).join('\n');
expect(summary).toMatchObject({
importRunId: '91',
counts: { discoveredSeasons: 1, importedSeasons: 1, importedFiles: 1, importedLines: 2 },
});
expect(sql).toContain('INSERT INTO "legacy_archive"."general_battle_result"');
expect(sql).toContain('DELETE FROM "legacy_archive"."general_battle_result"');
expect(sql).toContain('INSERT INTO "legacy_archive"."battle_result_import_checkpoint"');
expect(target.queries.some((entry) => entry.sql === 'BEGIN')).toBe(true);
expect(target.queries.some((entry) => entry.sql === 'COMMIT')).toBe(true);
});
it('skips an unchanged checkpoint during incremental import', async () => {
const source = await sourceFixture();
const [manifest] = await listBattleResultSeasons(source, 'che');
const target = targetPool([
{
source_fingerprint: source.identity.fingerprint,
server_id: manifest!.serverId,
manifest_hash: manifest!.manifestHash,
file_count: manifest!.fileCount,
total_bytes: String(manifest!.totalBytes),
},
]);
const summary = await migrateBattleResults(target.pool, source, false, 'che', {
mode: 'incremental',
source: source.identity,
});
expect(summary.counts).toMatchObject({ unchangedSeasons: 1, pendingSeasons: 0, importedFiles: 0 });
expect(target.queries.some((entry) => entry.sql.includes('general_battle_result'))).toBe(false);
});
it('reuses manifests collected by plan preflight instead of scanning the source twice', async () => {
const source = await sourceFixture();
const manifests = await listBattleResultSeasons(source, 'che');
await rename(source.directory, `${source.directory}-moved`);
temporaryDirectories.push(`${source.directory}-moved`);
const target = targetPool();
const summary = await migrateBattleResults(
target.pool,
source,
false,
'che',
{ mode: 'full', source: source.identity },
manifests
);
expect(summary.counts).toMatchObject({ discoveredSeasons: 1, pendingSeasons: 1 });
});
it('rejects changed checkpointed content in incremental mode', async () => {
const source = await sourceFixture();
const [manifest] = await listBattleResultSeasons(source, 'che');
const target = targetPool([
{
source_fingerprint: source.identity.fingerprint,
server_id: manifest!.serverId,
manifest_hash: 'c'.repeat(64),
file_count: manifest!.fileCount,
total_bytes: String(manifest!.totalBytes),
},
]);
await expect(
migrateBattleResults(target.pool, source, false, 'che', {
mode: 'incremental',
source: source.identity,
})
).rejects.toThrow('changed after checkpoint');
});
it('rejects a disappeared checkpointed season in full mode instead of leaving stale target rows', async () => {
const source = await sourceFixture();
const target = targetPool([
{
source_fingerprint: source.identity.fingerprint,
server_id: 'che_180101_missing',
manifest_hash: 'c'.repeat(64),
file_count: 1,
total_bytes: '10',
},
]);
await expect(
migrateBattleResults(target.pool, source, false, 'che', {
mode: 'full',
source: source.identity,
})
).rejects.toThrow('seasons disappeared from the source');
});
it('rolls back the current season and records a failed run', async () => {
const source = await sourceFixture();
const target = targetPool([], 'general_battle_result');
await expect(
migrateBattleResults(target.pool, source, true, 'che', { mode: 'full', source: source.identity })
).rejects.toThrow('synthetic battle-result write failure');
expect(target.queries.some((entry) => entry.sql === 'ROLLBACK')).toBe(true);
expect(target.queries.some((entry) => entry.sql.includes(`"status" = 'FAILED'`))).toBe(true);
});
});
@@ -0,0 +1 @@
이 페이즈 상세 로그는 이관하면 안 된다.
@@ -0,0 +1,2 @@
189년 4월: 테스트 장수 승리
189년 3월: 테스트 장수 패배
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import type { ResolvedMigrationStage } from '../src/config.js';
import { migrationInventoryForStage } from '../src/inventory.js';
const gameStage = (withBattleResults: boolean): ResolvedMigrationStage => ({
kind: 'game',
name: 'che',
profile: 'che',
sourceUrl: 'mariadb://source.invalid/che',
targetUrl: 'postgresql://target.invalid/game',
sourceIdentity: { key: 'fixture:che', fingerprint: 'a'.repeat(64) },
...(withBattleResults
? {
battleResults: {
kind: 'ssh' as const,
directory: '/srv/sammo/che/logs/preserved',
sshHost: 'serv',
identity: { key: 'fixture:che:battle-results', fingerprint: 'b'.repeat(64) },
},
}
: {}),
});
describe('migration plan inventory', () => {
it('lists each gateway item with its transferred information', () => {
const inventory = migrationInventoryForStage({
kind: 'gateway',
name: 'gateway',
sourceUrl: 'mariadb://source.invalid/root',
targetUrl: 'postgresql://target.invalid/gateway',
sourceIdentity: { key: 'fixture:gateway', fingerprint: 'a'.repeat(64) },
});
expect(inventory.map((item) => item.source)).toEqual([
'member',
'member_log',
'banned_member',
'storage',
'system',
]);
expect(inventory.every((item) => item.contents.length > 0)).toBe(true);
});
it('lists batres only when that filesystem source is configured', () => {
expect(migrationInventoryForStage(gameStage(false)).some((item) => item.strategy === 'filesystem-season')).toBe(
false
);
expect(migrationInventoryForStage(gameStage(true))).toContainEqual(
expect.objectContaining({
source: 'logs/preserved/<server_id>/batres<general_no>.txt',
strategy: 'filesystem-season',
contents: expect.stringContaining('batlog 페이즈 상세는 제외'),
})
);
});
});