merge: 최신 main을 Profile 포괄 권한 제거에 통합
This commit is contained in:
@@ -5,9 +5,10 @@ into the core2026 PostgreSQL schemas. It is CLI-only; no HTTP or administrator
|
||||
route invokes it.
|
||||
|
||||
The default mode is a read-only dry-run. `--apply` is required before any target
|
||||
write. PostgreSQL advisory locks prevent two applies for the same target. Every
|
||||
write uses a stable legacy key and `ON CONFLICT`, so a completed or interrupted
|
||||
run can be repeated.
|
||||
write. PostgreSQL advisory locks prevent two applies for the same target.
|
||||
Gateway writes are transactional. Game archive writes and their completed
|
||||
`legacy_archive.import_run` record are transactional. Stable legacy keys make
|
||||
completed or interrupted runs repeatable.
|
||||
|
||||
## Source restore
|
||||
|
||||
@@ -37,6 +38,13 @@ LEGACY_GAME_DATABASE_URL=... pnpm --filter @sammo-ts/legacy-db-migration migrate
|
||||
After reviewing the JSON counts and excluded-table reasons, add
|
||||
`GATEWAY_DATABASE_URL` or `GAME_DATABASE_URL` and repeat with `--apply`.
|
||||
|
||||
For game archives, `GAME_DATABASE_URL` points at that profile's Core schema.
|
||||
The importer writes completed-history data to the shared
|
||||
`legacy_archive` PostgreSQL schema and writes only inheritance projections to
|
||||
the selected current profile schema. Accepted profiles are
|
||||
`che,kwe,pwe,twe,nya,pya,hwe`; run them separately against the same PostgreSQL
|
||||
database.
|
||||
|
||||
### Isolated current-season comparison fixture
|
||||
|
||||
`current-season-fixture` is separate from the long-lived archive migration. It
|
||||
@@ -77,13 +85,17 @@ listed in the JSON result. This fixture is evidence for persisted-state and GUI
|
||||
comparison, not proof that the two engines consume RNG identically after the
|
||||
next turn.
|
||||
|
||||
Kakao members retain their OAuth ID, email, and OAuth metadata.
|
||||
`kakao_verified_at` and `kakao_grace_started_at` are set to the migration time.
|
||||
Kakao members retain their OAuth ID, email, and OAuth metadata. Only a row with
|
||||
a non-empty OAuth ID receives `kakao_verified_at`.
|
||||
`kakao_grace_started_at` is set to the migration time.
|
||||
The existing `token_valid_until` is copied to `kakao_talk_verified_until` for
|
||||
Kakao rows so a still-current “send to me” proof remains current after cutover.
|
||||
Legacy password hashes and salts are retained and upgraded to Argon2id after
|
||||
the first successful login when
|
||||
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is configured in gateway-api.
|
||||
Imported 128-hex password hashes are marked for reset. They can be upgraded to
|
||||
Argon2id after the first successful login only when the DB-external
|
||||
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is safely recovered. Otherwise, a verified
|
||||
Kakao flow requires a new password before session issuance. A non-Kakao account
|
||||
uses the CLI reset below. Reapplying a dump preserves the target account's
|
||||
current credential, OAuth, identity, roles, sanctions, consent, and login state.
|
||||
|
||||
Only tables present in the checked ref schemas are eligible. Extra tables found
|
||||
in a dump, such as an old root `config` table, are left in the recovery dump and
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"migrate": "tsx src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sammo-ts/common": "workspace:*",
|
||||
"mariadb": "3.5.3",
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import { createMariaPool, createPostgresPool } from './db.js';
|
||||
import { migrateGame } from './game.js';
|
||||
import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, migrateGame } from './game.js';
|
||||
import { migrateGateway } from './gateway.js';
|
||||
import { hashPasswordForReset } from './password.js';
|
||||
import { migrateCurrentSeasonFixture } from './currentSeason.js';
|
||||
@@ -122,7 +122,10 @@ const resetPassword = async (options: CliOptions): Promise<Record<string, unknow
|
||||
const hashed = await hashPasswordForReset(password);
|
||||
await pool.query(
|
||||
`UPDATE "app_user"
|
||||
SET "password_hash" = $1, "password_salt" = $2, "updated_at" = CURRENT_TIMESTAMP
|
||||
SET "password_hash" = $1,
|
||||
"password_salt" = $2,
|
||||
"password_reset_required" = FALSE,
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE "id" = $3`,
|
||||
[hashed.hash, hashed.salt, existing.rows[0]!.id]
|
||||
);
|
||||
@@ -142,7 +145,11 @@ const run = async (): Promise<void> => {
|
||||
const migratedAt = new Date();
|
||||
if (options.command === 'gateway') {
|
||||
const source = createMariaPool(requireEnvironment('LEGACY_ROOT_DATABASE_URL'));
|
||||
const target = options.apply ? createPostgresPool(requireEnvironment('GATEWAY_DATABASE_URL')) : null;
|
||||
const targetUrl = process.env.GATEWAY_DATABASE_URL?.trim();
|
||||
if (options.apply && !targetUrl) {
|
||||
throw new Error('GATEWAY_DATABASE_URL is required with --apply');
|
||||
}
|
||||
const target = targetUrl ? createPostgresPool(targetUrl) : null;
|
||||
try {
|
||||
const summary = await migrateGateway(source, target, options.apply, migratedAt);
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
@@ -156,6 +163,9 @@ const run = async (): Promise<void> => {
|
||||
if (!options.profile || !/^[a-z][a-z0-9_-]{1,31}$/.test(options.profile)) {
|
||||
throw new Error(`${options.command} requires a safe --profile value\n\n${usage}`);
|
||||
}
|
||||
if (options.command === 'game' && !isLegacyArchiveProfile(options.profile)) {
|
||||
throw new Error(`game requires --profile ${LEGACY_ARCHIVE_PROFILES.join('|')}\n\n${usage}`);
|
||||
}
|
||||
const source = createMariaPool(requireEnvironment('LEGACY_GAME_DATABASE_URL'));
|
||||
const target =
|
||||
options.apply || options.command === 'current-season-fixture'
|
||||
|
||||
@@ -24,6 +24,14 @@ const quoteIdentifier = (value: string): string => {
|
||||
return `"${value}"`;
|
||||
};
|
||||
|
||||
export const quoteQualifiedIdentifier = (value: string): string => {
|
||||
const parts = value.split('.');
|
||||
if (parts.length < 1 || parts.length > 2 || parts.some((part) => !IDENTIFIER.test(part))) {
|
||||
throw new Error(`Unsafe SQL identifier: ${value}`);
|
||||
}
|
||||
return parts.map((part) => `"${part}"`).join('.');
|
||||
};
|
||||
|
||||
export const createMariaPool = (uri: string): MariaPool => mariadb.createPool(uri);
|
||||
|
||||
export const createPostgresPool = (connectionString: string): pg.Pool => {
|
||||
@@ -94,7 +102,8 @@ export const upsertRows = async (
|
||||
client: PoolClient,
|
||||
table: string,
|
||||
rows: readonly TargetRow[],
|
||||
conflictColumns: readonly string[]
|
||||
conflictColumns: readonly string[],
|
||||
options: { preserveOnConflict?: readonly string[] } = {}
|
||||
): Promise<void> => {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
@@ -116,12 +125,13 @@ export const upsertRows = async (
|
||||
});
|
||||
return `(${placeholders.join(', ')})`;
|
||||
});
|
||||
const preserved = new Set(options.preserveOnConflict ?? []);
|
||||
const updates = columns
|
||||
.filter((column) => !conflictColumns.includes(column))
|
||||
.filter((column) => !conflictColumns.includes(column) && !preserved.has(column))
|
||||
.map((column) => `${quoteIdentifier(column)} = EXCLUDED.${quoteIdentifier(column)}`);
|
||||
const conflictAction = updates.length ? `DO UPDATE SET ${updates.join(', ')}` : 'DO NOTHING';
|
||||
await client.query(
|
||||
`INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(', ')})
|
||||
`INSERT INTO ${quoteQualifiedIdentifier(table)} (${columns.map(quoteIdentifier).join(', ')})
|
||||
VALUES ${tuples.join(', ')}
|
||||
ON CONFLICT (${conflictColumns.map(quoteIdentifier).join(', ')}) ${conflictAction}`,
|
||||
values
|
||||
|
||||
@@ -3,6 +3,16 @@ import { createHash } from 'node:crypto';
|
||||
import type { Pool as MariaPool } from 'mariadb';
|
||||
import type { Pool as PgPool, PoolClient } from 'pg';
|
||||
|
||||
import {
|
||||
isLegacyArchiveProfile,
|
||||
normalizeArchivedGeneral,
|
||||
type ArchivedGeneralSourceFormat,
|
||||
type ArchivedJsonValue,
|
||||
type LegacyArchiveProfile,
|
||||
} from '@sammo-ts/common';
|
||||
|
||||
export { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from '@sammo-ts/common';
|
||||
|
||||
import {
|
||||
paginateSource,
|
||||
jsonParameter,
|
||||
@@ -29,6 +39,12 @@ import {
|
||||
|
||||
const batchSize = 250;
|
||||
|
||||
interface ArchiveMigrationContext {
|
||||
profile: LegacyArchiveProfile;
|
||||
importRunId: string;
|
||||
sourceFormats: Record<ArchivedGeneralSourceFormat, number>;
|
||||
}
|
||||
|
||||
const parseNullableJson = (value: unknown, fallback: JsonValue, context: string): JsonValue =>
|
||||
value === null || value === undefined ? fallback : parseJson(value, context);
|
||||
|
||||
@@ -43,17 +59,17 @@ const ownerId = (value: unknown): string | null => {
|
||||
return memberNo > 0 ? legacyUserId(memberNo) : null;
|
||||
};
|
||||
|
||||
const hashYearbook = (row: TargetRow): string =>
|
||||
createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
map: row.map,
|
||||
nations: row.nations,
|
||||
globalHistory: row.global_history,
|
||||
globalAction: row.global_action,
|
||||
})
|
||||
)
|
||||
.digest('hex');
|
||||
const hashYearbook = (map: JsonValue, nations: JsonValue, globalHistory: JsonValue, globalAction: JsonValue): string =>
|
||||
createHash('sha256').update(JSON.stringify({ map, nations, globalHistory, globalAction })).digest('hex');
|
||||
|
||||
const asJsonRecord = (value: JsonValue): Record<string, JsonValue> =>
|
||||
value !== null && !Array.isArray(value) && typeof value === 'object' ? value : {};
|
||||
|
||||
export const resolveLegacyGameOpenedAt = (env: JsonValue, legacyDate: Date, context: string): Date => {
|
||||
const record = asJsonRecord(env);
|
||||
const candidate = record.opentime ?? record.starttime;
|
||||
return candidate === null || candidate === undefined || candidate === '' ? legacyDate : toDate(candidate, context);
|
||||
};
|
||||
|
||||
const migrateSimpleTable = async (
|
||||
source: MariaPool,
|
||||
@@ -75,17 +91,24 @@ const migrateSimpleTable = async (
|
||||
}
|
||||
};
|
||||
|
||||
const migrateHall = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> =>
|
||||
const migrateHall = (
|
||||
source: MariaPool,
|
||||
target: PoolClient | null,
|
||||
counts: Record<string, number>,
|
||||
archive: ArchiveMigrationContext
|
||||
): Promise<void> =>
|
||||
migrateSimpleTable(
|
||||
source,
|
||||
target,
|
||||
'hall',
|
||||
'id',
|
||||
'hall',
|
||||
['server_id', 'type', 'general_no'],
|
||||
'legacy_archive.hall',
|
||||
['source_profile', 'server_id', 'type', 'general_no'],
|
||||
(row) => {
|
||||
const sourceId = toNumber(row.id, 'hall.id');
|
||||
return {
|
||||
source_profile: archive.profile,
|
||||
legacy_id: sourceId,
|
||||
server_id: toStringValue(row.server_id, `hall.${sourceId}.server_id`),
|
||||
season: toNumber(row.season, `hall.${sourceId}.season`),
|
||||
scenario: toNumber(row.scenario, `hall.${sourceId}.scenario`),
|
||||
@@ -94,24 +117,36 @@ const migrateHall = (source: MariaPool, target: PoolClient | null, counts: Recor
|
||||
value: toFloat(row.value, `hall.${sourceId}.value`),
|
||||
owner: ownerId(row.owner),
|
||||
aux: parseJson(row.aux, `hall.${sourceId}.aux`),
|
||||
import_run_id: archive.importRunId,
|
||||
};
|
||||
},
|
||||
counts
|
||||
);
|
||||
|
||||
const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> =>
|
||||
const migrateGames = (
|
||||
source: MariaPool,
|
||||
target: PoolClient | null,
|
||||
counts: Record<string, number>,
|
||||
archive: ArchiveMigrationContext
|
||||
): Promise<void> =>
|
||||
migrateSimpleTable(
|
||||
source,
|
||||
target,
|
||||
'ng_games',
|
||||
'id',
|
||||
'ng_games',
|
||||
['server_id'],
|
||||
'legacy_archive.game_history',
|
||||
['source_profile', 'server_id'],
|
||||
(row) => {
|
||||
const sourceId = toNumber(row.id, 'ng_games.id');
|
||||
const legacyDate = toDate(row.date, `ng_games.${sourceId}.date`);
|
||||
const env = parseJson(row.env, `ng_games.${sourceId}.env`);
|
||||
return {
|
||||
source_profile: archive.profile,
|
||||
server_id: toStringValue(row.server_id, `ng_games.${sourceId}.server_id`),
|
||||
date: toDate(row.date, `ng_games.${sourceId}.date`),
|
||||
legacy_id: sourceId,
|
||||
opened_at: resolveLegacyGameOpenedAt(env, legacyDate, `ng_games.${sourceId}.opened_at`),
|
||||
completed_at: null,
|
||||
legacy_date: legacyDate,
|
||||
winner_nation:
|
||||
row.winner_nation === null
|
||||
? null
|
||||
@@ -120,7 +155,8 @@ const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Reco
|
||||
season: toNumber(row.season, `ng_games.${sourceId}.season`),
|
||||
scenario: toNumber(row.scenario, `ng_games.${sourceId}.scenario`),
|
||||
scenario_name: toStringValue(row.scenario_name, `ng_games.${sourceId}.scenario_name`),
|
||||
env: parseJson(row.env, `ng_games.${sourceId}.env`),
|
||||
raw_env: jsonParameter(env),
|
||||
import_run_id: archive.importRunId,
|
||||
};
|
||||
},
|
||||
counts
|
||||
@@ -129,25 +165,36 @@ const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Reco
|
||||
const migrateOldGenerals = (
|
||||
source: MariaPool,
|
||||
target: PoolClient | null,
|
||||
counts: Record<string, number>
|
||||
counts: Record<string, number>,
|
||||
archive: ArchiveMigrationContext
|
||||
): Promise<void> =>
|
||||
migrateSimpleTable(
|
||||
source,
|
||||
target,
|
||||
'ng_old_generals',
|
||||
'id',
|
||||
'ng_old_generals',
|
||||
['server_id', 'general_no'],
|
||||
'legacy_archive.general',
|
||||
['source_profile', 'server_id', 'general_no'],
|
||||
(row) => {
|
||||
const sourceId = toNumber(row.id, 'ng_old_generals.id');
|
||||
const name = toStringValue(row.name, `ng_old_generals.${sourceId}.name`);
|
||||
const rawData = parseJson(row.data, `ng_old_generals.${sourceId}.data`);
|
||||
const normalized = normalizeArchivedGeneral(rawData as ArchivedJsonValue, name);
|
||||
archive.sourceFormats[normalized.sourceFormat] += 1;
|
||||
return {
|
||||
source_profile: archive.profile,
|
||||
server_id: toStringValue(row.server_id, `ng_old_generals.${sourceId}.server_id`),
|
||||
general_no: toNumber(row.general_no, `ng_old_generals.${sourceId}.general_no`),
|
||||
legacy_id: sourceId,
|
||||
owner: ownerId(row.owner),
|
||||
name: toStringValue(row.name, `ng_old_generals.${sourceId}.name`),
|
||||
name,
|
||||
last_yearmonth: toNumber(row.last_yearmonth, `ng_old_generals.${sourceId}.last_yearmonth`),
|
||||
turntime: toDate(row.turntime, `ng_old_generals.${sourceId}.turntime`),
|
||||
data: parseJson(row.data, `ng_old_generals.${sourceId}.data`),
|
||||
schema_version: normalized.snapshot.schemaVersion,
|
||||
source_format: normalized.sourceFormat,
|
||||
data: jsonParameter(normalized.snapshot),
|
||||
raw_data: jsonParameter(rawData),
|
||||
import_run_id: archive.importRunId,
|
||||
};
|
||||
},
|
||||
counts
|
||||
@@ -156,41 +203,47 @@ const migrateOldGenerals = (
|
||||
const migrateOldNations = (
|
||||
source: MariaPool,
|
||||
target: PoolClient | null,
|
||||
counts: Record<string, number>
|
||||
counts: Record<string, number>,
|
||||
archive: ArchiveMigrationContext
|
||||
): Promise<void> =>
|
||||
migrateSimpleTable(
|
||||
source,
|
||||
target,
|
||||
'ng_old_nations',
|
||||
'id',
|
||||
'ng_old_nations',
|
||||
['server_id', 'nation', 'source_id'],
|
||||
'legacy_archive.nation',
|
||||
['source_profile', 'legacy_id'],
|
||||
(row) => {
|
||||
const sourceId = toNumber(row.id, 'ng_old_nations.id');
|
||||
return {
|
||||
source_profile: archive.profile,
|
||||
legacy_id: sourceId,
|
||||
server_id: toStringValue(row.server_id, `ng_old_nations.${sourceId}.server_id`),
|
||||
nation: toNumber(row.nation, `ng_old_nations.${sourceId}.nation`),
|
||||
source_id: sourceId,
|
||||
data: parseJson(row.data, `ng_old_nations.${sourceId}.data`),
|
||||
date: toDate(row.date, `ng_old_nations.${sourceId}.date`),
|
||||
data: jsonParameter(parseJson(row.data, `ng_old_nations.${sourceId}.data`)),
|
||||
archived_at: toDate(row.date, `ng_old_nations.${sourceId}.date`),
|
||||
import_run_id: archive.importRunId,
|
||||
};
|
||||
},
|
||||
counts
|
||||
);
|
||||
|
||||
const migrateEmperors = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> =>
|
||||
const migrateEmperors = (
|
||||
source: MariaPool,
|
||||
target: PoolClient | null,
|
||||
counts: Record<string, number>,
|
||||
archive: ArchiveMigrationContext
|
||||
): Promise<void> =>
|
||||
migrateSimpleTable(
|
||||
source,
|
||||
target,
|
||||
'emperior',
|
||||
'no',
|
||||
'emperior',
|
||||
['legacy_id'],
|
||||
'legacy_archive.emperor',
|
||||
['source_profile', 'legacy_id'],
|
||||
(row) => {
|
||||
const id = toNumber(row.no, 'emperior.no');
|
||||
return {
|
||||
legacy_id: id,
|
||||
server_id: toNullableString(row.server_id),
|
||||
const data = {
|
||||
phase: toNullableString(row.phase),
|
||||
nation_count: toNullableString(row.nation_count),
|
||||
nation_name: toNullableString(row.nation_name),
|
||||
@@ -232,6 +285,13 @@ const migrateEmperors = (source: MariaPool, target: PoolClient | null, counts: R
|
||||
history: parseNullableJson(row.history, [], `emperior.${id}.history`),
|
||||
aux: parseNullableJson(row.aux, {}, `emperior.${id}.aux`),
|
||||
};
|
||||
return {
|
||||
source_profile: archive.profile,
|
||||
legacy_id: id,
|
||||
server_id: toNullableString(row.server_id),
|
||||
data: jsonParameter(data),
|
||||
import_run_id: archive.importRunId,
|
||||
};
|
||||
},
|
||||
counts
|
||||
);
|
||||
@@ -295,30 +355,35 @@ const migrateUserRecords = (
|
||||
const migrateYearbook = async (
|
||||
source: MariaPool,
|
||||
target: PoolClient | null,
|
||||
counts: Record<string, number>
|
||||
counts: Record<string, number>,
|
||||
archive: ArchiveMigrationContext
|
||||
): Promise<void> => {
|
||||
await migrateSimpleTable(
|
||||
source,
|
||||
target,
|
||||
'ng_history',
|
||||
'no',
|
||||
'yearbook_history',
|
||||
['profile_name', 'year', 'month', 'source_id'],
|
||||
'legacy_archive.yearbook',
|
||||
['source_profile', 'legacy_id'],
|
||||
(row) => {
|
||||
const id = toNumber(row.no, 'ng_history.no');
|
||||
const map = parseNullableJson(row.map, {}, `ng_history.${id}.map`);
|
||||
const nations = parseNullableJson(row.nations, [], `ng_history.${id}.nations`);
|
||||
const globalHistory = parseNullableJson(row.global_history, [], `ng_history.${id}.global_history`);
|
||||
const globalAction = parseNullableJson(row.global_action, [], `ng_history.${id}.global_action`);
|
||||
const mapped: TargetRow = {
|
||||
source_profile: archive.profile,
|
||||
legacy_id: id,
|
||||
profile_name: toStringValue(row.server_id, `ng_history.${id}.server_id`),
|
||||
source_id: id,
|
||||
year: toNumber(row.year, `ng_history.${id}.year`),
|
||||
month: toNumber(row.month, `ng_history.${id}.month`),
|
||||
map: parseNullableJson(row.map, {}, `ng_history.${id}.map`),
|
||||
nations: parseNullableJson(row.nations, [], `ng_history.${id}.nations`),
|
||||
global_history: parseNullableJson(row.global_history, [], `ng_history.${id}.global_history`),
|
||||
global_action: parseNullableJson(row.global_action, [], `ng_history.${id}.global_action`),
|
||||
hash: '',
|
||||
created_at: new Date(0),
|
||||
map: jsonParameter(map),
|
||||
nations: jsonParameter(nations),
|
||||
global_history: jsonParameter(globalHistory),
|
||||
global_action: jsonParameter(globalAction),
|
||||
content_hash: hashYearbook(map, nations, globalHistory, globalAction),
|
||||
import_run_id: archive.importRunId,
|
||||
};
|
||||
mapped.hash = hashYearbook(mapped);
|
||||
return mapped;
|
||||
},
|
||||
counts,
|
||||
@@ -388,7 +453,16 @@ export const migrateGame = async (
|
||||
apply: boolean,
|
||||
profile: string
|
||||
): Promise<MigrationSummary> => {
|
||||
if (!isLegacyArchiveProfile(profile)) {
|
||||
throw new Error(`Unsupported legacy archive profile: ${profile}`);
|
||||
}
|
||||
const counts: Record<string, number> = {};
|
||||
const sourceFormats: Record<ArchivedGeneralSourceFormat, number> = {
|
||||
'legacy-flat-v0': 0,
|
||||
'ref-flat-v1': 0,
|
||||
'core-snapshot-v1': 0,
|
||||
unknown: 0,
|
||||
};
|
||||
const excluded = {
|
||||
general: 'Current-season actor state is intentionally not transferred.',
|
||||
city: 'Current-season world state is intentionally not transferred.',
|
||||
@@ -421,25 +495,59 @@ export const migrateGame = async (
|
||||
'storage:season-state': 'Only inheritance_* and user_* long-lived namespaces are archived or projected.',
|
||||
};
|
||||
const client = apply && targetPool ? await targetPool.connect() : null;
|
||||
let importRunId: string | null = null;
|
||||
try {
|
||||
const run = async (): Promise<void> => {
|
||||
await migrateGames(source, client, counts);
|
||||
await migrateHall(source, client, counts);
|
||||
await migrateOldGenerals(source, client, counts);
|
||||
await migrateOldNations(source, client, counts);
|
||||
await migrateEmperors(source, client, counts);
|
||||
const run = async (archive: ArchiveMigrationContext): Promise<void> => {
|
||||
await migrateGames(source, client, counts, archive);
|
||||
await migrateHall(source, client, counts, archive);
|
||||
await migrateOldGenerals(source, client, counts, archive);
|
||||
await migrateOldNations(source, client, counts, archive);
|
||||
await migrateEmperors(source, client, counts, archive);
|
||||
await migrateInheritanceResults(source, client, counts);
|
||||
await migrateUserRecords(source, client, counts);
|
||||
await migrateStorage(source, client, counts);
|
||||
await migrateYearbook(source, client, counts);
|
||||
await migrateYearbook(source, client, counts, archive);
|
||||
};
|
||||
if (client) {
|
||||
await withMigrationLock(client, `sammo-legacy-game-v1:${profile}`, run);
|
||||
await withMigrationLock(client, `sammo-legacy-archive-v2:${profile}`, async () => {
|
||||
const created = await client.query<{ id: string }>(
|
||||
`INSERT INTO "legacy_archive"."import_run" ("source_profile", "status")
|
||||
VALUES ($1, 'RUNNING') RETURNING "id"`,
|
||||
[profile]
|
||||
);
|
||||
importRunId = created.rows[0]?.id ?? null;
|
||||
if (!importRunId) throw new Error('Failed to create legacy archive import run');
|
||||
const archive: ArchiveMigrationContext = { profile, importRunId, sourceFormats };
|
||||
await client.query('BEGIN');
|
||||
try {
|
||||
await run(archive);
|
||||
await client.query(
|
||||
`UPDATE "legacy_archive"."import_run"
|
||||
SET "status" = 'COMPLETED', "finished_at" = CURRENT_TIMESTAMP,
|
||||
"counts" = $2::jsonb, "source_format_summary" = $3::jsonb
|
||||
WHERE "id" = $1`,
|
||||
[importRunId, JSON.stringify(counts), JSON.stringify(sourceFormats)]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
const message =
|
||||
error instanceof Error ? error.message.slice(0, 2000) : String(error).slice(0, 2000);
|
||||
await client.query(
|
||||
`UPDATE "legacy_archive"."import_run"
|
||||
SET "status" = 'FAILED', "finished_at" = CURRENT_TIMESTAMP,
|
||||
"counts" = $2::jsonb, "source_format_summary" = $3::jsonb, "error" = $4
|
||||
WHERE "id" = $1`,
|
||||
[importRunId, JSON.stringify(counts), JSON.stringify(sourceFormats), message]
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await run();
|
||||
await run({ profile, importRunId: '0', sourceFormats });
|
||||
}
|
||||
} finally {
|
||||
client?.release();
|
||||
}
|
||||
return { command: 'game', apply, counts, excluded };
|
||||
return { command: 'game', apply, counts, excluded, importRunId, sourceFormatSummary: sourceFormats };
|
||||
};
|
||||
|
||||
@@ -24,17 +24,83 @@ export interface MigrationSummary {
|
||||
apply: boolean;
|
||||
counts: Record<string, number>;
|
||||
excluded: Record<string, string>;
|
||||
importRunId?: string | null;
|
||||
sourceFormatSummary?: Record<string, number>;
|
||||
}
|
||||
|
||||
const batchSize = 500;
|
||||
|
||||
const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): TargetRow => {
|
||||
export const MEMBER_PRESERVED_COLUMNS = [
|
||||
'login_id',
|
||||
'display_name',
|
||||
'password_hash',
|
||||
'password_salt',
|
||||
'password_reset_required',
|
||||
'roles',
|
||||
'sanctions',
|
||||
'oauth_type',
|
||||
'oauth_id',
|
||||
'email',
|
||||
'oauth_info',
|
||||
'picture',
|
||||
'image_server',
|
||||
'icon_updated_at',
|
||||
'third_party_use',
|
||||
'terms_accepted_at',
|
||||
'privacy_accepted_at',
|
||||
'kakao_verified_at',
|
||||
'kakao_talk_verified_until',
|
||||
'kakao_grace_started_at',
|
||||
'delete_after',
|
||||
'updated_at',
|
||||
'last_login_at',
|
||||
'created_at',
|
||||
] as const;
|
||||
|
||||
export const preflightMemberConflicts = async (target: PoolClient, rows: readonly TargetRow[]): Promise<void> => {
|
||||
if (rows.length === 0) return;
|
||||
const ids = rows.map((row) => String(row.id));
|
||||
const loginIds = rows.map((row) => String(row.login_id));
|
||||
const displayNames = rows.map((row) => String(row.display_name));
|
||||
const emails = rows.map((row) => row.email).filter((value): value is string => typeof value === 'string');
|
||||
const existing = await target.query<{
|
||||
id: string;
|
||||
login_id: string;
|
||||
display_name: string;
|
||||
email: string | null;
|
||||
}>(
|
||||
`SELECT "id", "login_id", "display_name", "email"
|
||||
FROM "app_user"
|
||||
WHERE "id" = ANY($1::text[])
|
||||
OR "login_id" = ANY($2::text[])
|
||||
OR "display_name" = ANY($3::text[])
|
||||
OR "email" = ANY($4::text[])`,
|
||||
[ids, loginIds, displayNames, emails]
|
||||
);
|
||||
for (const row of rows) {
|
||||
const id = String(row.id);
|
||||
const collision = existing.rows.find(
|
||||
(candidate) =>
|
||||
candidate.id !== id &&
|
||||
(candidate.login_id === row.login_id ||
|
||||
candidate.display_name === row.display_name ||
|
||||
(row.email !== null && candidate.email === row.email))
|
||||
);
|
||||
if (collision) {
|
||||
throw new Error('Target account identity collision in legacy member batch');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null): TargetRow => {
|
||||
const memberNo = toNumber(row.NO, 'member.NO');
|
||||
const grade = toNumber(row.GRADE, `member.${memberNo}.GRADE`);
|
||||
const acl = parseJson(row.acl, `member.${memberNo}.acl`);
|
||||
const penalty = parseJson(row.penalty, `member.${memberNo}.penalty`);
|
||||
const oauthInfo = parseJson(row.oauth_info, `member.${memberNo}.oauth_info`);
|
||||
const oauthType = row.oauth_type === 'KAKAO' ? 'KAKAO' : 'NONE';
|
||||
const oauthId = toNullableString(row.oauth_id)?.trim() || null;
|
||||
const passwordHash = toStringValue(row.PW, `member.${memberNo}.PW`);
|
||||
const legacyData: JsonValue = {
|
||||
memberNo,
|
||||
grade,
|
||||
@@ -49,12 +115,13 @@ const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null):
|
||||
id: legacyUserId(memberNo),
|
||||
login_id: toStringValue(row.ID, `member.${memberNo}.ID`).toLowerCase(),
|
||||
display_name: toStringValue(row.NAME, `member.${memberNo}.NAME`),
|
||||
password_hash: toStringValue(row.PW, `member.${memberNo}.PW`),
|
||||
password_hash: passwordHash,
|
||||
password_salt: toStringValue(row.salt, `member.${memberNo}.salt`),
|
||||
password_reset_required: /^[a-f0-9]{128}$/i.test(passwordHash),
|
||||
roles: jsonParameter(mapLegacyRoles(grade, acl)),
|
||||
sanctions: jsonParameter(mapLegacySanctions(grade, penalty)),
|
||||
oauth_type: oauthType,
|
||||
oauth_id: toNullableString(row.oauth_id),
|
||||
oauth_id: oauthId,
|
||||
email: toNullableString(row.EMAIL)?.toLowerCase() ?? null,
|
||||
oauth_info: jsonParameter(oauthInfo),
|
||||
picture: toNullableString(row.PICTURE) ?? 'default.jpg',
|
||||
@@ -63,9 +130,9 @@ const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null):
|
||||
third_party_use: toNumber(row.third_use ?? 0, `member.${memberNo}.third_use`) !== 0,
|
||||
terms_accepted_at: null,
|
||||
privacy_accepted_at: null,
|
||||
kakao_verified_at: oauthType === 'KAKAO' ? migratedAt : null,
|
||||
kakao_verified_at: oauthType === 'KAKAO' && oauthId ? migratedAt : null,
|
||||
kakao_talk_verified_until:
|
||||
oauthType === 'KAKAO'
|
||||
oauthType === 'KAKAO' && oauthId
|
||||
? toNullableDate(row.token_valid_until, `member.${memberNo}.token_valid_until`)
|
||||
: null,
|
||||
kakao_grace_started_at: migratedAt,
|
||||
@@ -93,6 +160,7 @@ const loadLastLogins = async (source: MariaPool): Promise<Map<number, Date>> =>
|
||||
const processMembers = async (
|
||||
source: MariaPool,
|
||||
target: PoolClient | null,
|
||||
apply: boolean,
|
||||
migratedAt: Date,
|
||||
counts: Record<string, number>
|
||||
): Promise<void> => {
|
||||
@@ -103,7 +171,10 @@ const processMembers = async (
|
||||
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null);
|
||||
});
|
||||
if (target) {
|
||||
await upsertRows(target, 'app_user', mapped, ['id']);
|
||||
await preflightMemberConflicts(target, mapped);
|
||||
}
|
||||
if (target && apply) {
|
||||
await upsertRows(target, 'app_user', mapped, ['id'], { preserveOnConflict: MEMBER_PRESERVED_COLUMNS });
|
||||
}
|
||||
counts.member = (counts.member ?? 0) + mapped.length;
|
||||
}
|
||||
@@ -206,17 +277,26 @@ export const migrateGateway = async (
|
||||
login_token:
|
||||
'Legacy bearer tokens, IP addresses, and expired sessions are not valid in the Redis session model.',
|
||||
};
|
||||
const client = apply && targetPool ? await targetPool.connect() : null;
|
||||
const client = targetPool ? await targetPool.connect() : null;
|
||||
try {
|
||||
const run = async (): Promise<void> => {
|
||||
await processMembers(source, client, migratedAt, counts);
|
||||
await processMemberLogs(source, client, counts);
|
||||
await processBannedMembers(source, client, counts);
|
||||
await processRootKeyValues(source, client, counts);
|
||||
await processSystem(source, client, counts);
|
||||
await processMembers(source, client, apply, migratedAt, counts);
|
||||
await processMemberLogs(source, apply ? client : null, counts);
|
||||
await processBannedMembers(source, apply ? client : null, counts);
|
||||
await processRootKeyValues(source, apply ? client : null, counts);
|
||||
await processSystem(source, apply ? client : null, counts);
|
||||
};
|
||||
if (client) {
|
||||
await withMigrationLock(client, 'sammo-legacy-gateway-v1', run);
|
||||
if (client && apply) {
|
||||
await withMigrationLock(client, 'sammo-legacy-gateway-v1', async () => {
|
||||
await client.query('BEGIN');
|
||||
try {
|
||||
await run();
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await run();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common';
|
||||
|
||||
const fixture = async (name: string): Promise<ArchivedJsonValue> =>
|
||||
JSON.parse(await readFile(new URL(`./fixtures/${name}`, import.meta.url), 'utf8')) as ArchivedJsonValue;
|
||||
|
||||
describe('normalizeArchivedGeneral', () => {
|
||||
it('normalizes the sanitized CHE legacy-flat keyset without leaking connection metadata', async () => {
|
||||
const { sourceFormat, snapshot } = normalizeArchivedGeneral(
|
||||
await fixture('che-old-general-legacy-flat-v0.json'),
|
||||
'fallback'
|
||||
);
|
||||
|
||||
expect(sourceFormat).toBe('legacy-flat-v0');
|
||||
expect(snapshot).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
identity: { name: '구형테스트장수', nationId: 3 },
|
||||
stats: { leadership: 81, strength: 73, intelligence: 66 },
|
||||
mastery: { infantry: 101, archery: 202, cavalry: 303, special: 404, siege: 505 },
|
||||
battle: {
|
||||
battles: 20,
|
||||
wins: 12,
|
||||
losses: 8,
|
||||
winRate: 60,
|
||||
killRate: 125,
|
||||
tactics: { total: { wins: 3, draws: 1, losses: 2 } },
|
||||
},
|
||||
history: ['<C>●</>첫 기록', '<Y>●</>둘째 기록'],
|
||||
availability: { mastery: true, battleAggregates: true, tactics: true },
|
||||
});
|
||||
expect(JSON.stringify(snapshot)).not.toMatch(/"(?:ip|lastconnect|refresh)"/iu);
|
||||
});
|
||||
|
||||
it('normalizes the sanitized HWE ref-flat keyset and marks absent battle records unavailable', async () => {
|
||||
const { sourceFormat, snapshot } = normalizeArchivedGeneral(
|
||||
await fixture('hwe-old-general-ref-flat-v1.json'),
|
||||
'fallback'
|
||||
);
|
||||
|
||||
expect(sourceFormat).toBe('ref-flat-v1');
|
||||
expect(snapshot).toMatchObject({
|
||||
identity: { name: '신형테스트장수', officerLevel: 7 },
|
||||
stats: {
|
||||
leadership: 91,
|
||||
strength: 82,
|
||||
intelligence: 74,
|
||||
leadershipExperience: 11,
|
||||
},
|
||||
traits: { personality: 'che_의리', specialDomestic: 'che_상재', specialWar: 'che_신산' },
|
||||
mastery: { infantry: 111, archery: 222, cavalry: 333, special: 444, siege: 555 },
|
||||
battle: { battles: null, wins: null, losses: null, winRate: null, killRate: null },
|
||||
availability: { mastery: true, battleAggregates: false, tactics: false },
|
||||
});
|
||||
expect(snapshot.availability.battleDetailLogs).toBe(false);
|
||||
expect(snapshot.availability.battleResultLogs).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an already-normalized version 1 snapshot stable', async () => {
|
||||
const first = normalizeArchivedGeneral(await fixture('che-old-general-legacy-flat-v0.json'), 'fallback');
|
||||
const second = normalizeArchivedGeneral(
|
||||
first.snapshot as unknown as ArchivedJsonValue,
|
||||
first.snapshot.identity.name
|
||||
);
|
||||
|
||||
expect(second.sourceFormat).toBe('core-snapshot-v1');
|
||||
expect(second.snapshot).toEqual(first.snapshot);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { PoolClient } from 'pg';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { quoteQualifiedIdentifier, upsertRows } from '../src/db.js';
|
||||
|
||||
describe('qualified archive identifiers', () => {
|
||||
it('quotes a schema-qualified table and still parameterizes values', async () => {
|
||||
const query = vi.fn().mockResolvedValue({});
|
||||
await upsertRows(
|
||||
{ query } as unknown as PoolClient,
|
||||
'legacy_archive.general',
|
||||
[{ id: 1, data: { ok: true } }],
|
||||
['id']
|
||||
);
|
||||
expect(query).toHaveBeenCalledOnce();
|
||||
expect(query.mock.calls[0]?.[0]).toContain('INSERT INTO "legacy_archive"."general"');
|
||||
expect(query.mock.calls[0]?.[1]).toEqual([1, JSON.stringify({ ok: true })]);
|
||||
});
|
||||
|
||||
it.each(['legacy_archive.general.extra', 'legacy-archive.general', 'legacy_archive.General', 'public.;drop'])(
|
||||
'rejects unsafe qualified identifier %s',
|
||||
(value) => expect(() => quoteQualifiedIdentifier(value)).toThrow('Unsafe SQL identifier')
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "구형테스트장수",
|
||||
"leader": 81,
|
||||
"power": 73,
|
||||
"intel": 66,
|
||||
"leader2": 4,
|
||||
"power2": 5,
|
||||
"intel2": 6,
|
||||
"nation": 3,
|
||||
"city": 7,
|
||||
"level": 8,
|
||||
"personal": 2,
|
||||
"special": 4,
|
||||
"special2": 5,
|
||||
"experience": 12345,
|
||||
"explevel": 8,
|
||||
"dedication": 765,
|
||||
"dedlevel": 4,
|
||||
"dex0": 101,
|
||||
"dex10": 202,
|
||||
"dex20": 303,
|
||||
"dex30": 404,
|
||||
"dex40": 505,
|
||||
"warnum": 20,
|
||||
"killnum": 12,
|
||||
"deathnum": 8,
|
||||
"firenum": 7,
|
||||
"killcrew": 2500,
|
||||
"deathcrew": 2000,
|
||||
"ttw": 3,
|
||||
"ttd": 1,
|
||||
"ttl": 2,
|
||||
"tlw": 4,
|
||||
"tld": 0,
|
||||
"tll": 1,
|
||||
"tiw": 5,
|
||||
"tid": 2,
|
||||
"til": 3,
|
||||
"picture": "default.jpg",
|
||||
"imgsvr": 0,
|
||||
"horse": 1,
|
||||
"weap": 2,
|
||||
"book": 3,
|
||||
"item": 4,
|
||||
"history": "<C>●</>첫 기록<br><Y>●</>둘째 기록<br>",
|
||||
"recent_war": "2020-01-02 03:04:05",
|
||||
"ip": "192.0.2.1",
|
||||
"lastconnect": "2020-01-02 03:04:05",
|
||||
"refresh": 10
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "신형테스트장수",
|
||||
"leadership": 91,
|
||||
"strength": 82,
|
||||
"intel": 74,
|
||||
"leadership_exp": 11,
|
||||
"strength_exp": 12,
|
||||
"intel_exp": 13,
|
||||
"nation": 4,
|
||||
"city": 8,
|
||||
"officer_level": 7,
|
||||
"officer_city": 8,
|
||||
"personal": "che_의리",
|
||||
"special": "che_상재",
|
||||
"special2": "che_신산",
|
||||
"experience": 22222,
|
||||
"explevel": 9,
|
||||
"dedication": 999,
|
||||
"dedlevel": 5,
|
||||
"dex1": 111,
|
||||
"dex2": 222,
|
||||
"dex3": 333,
|
||||
"dex4": 444,
|
||||
"dex5": 555,
|
||||
"picture": "default.jpg",
|
||||
"imgsvr": 1,
|
||||
"horse": "che_적토마",
|
||||
"weapon": "che_청룡언월도",
|
||||
"book": null,
|
||||
"item": null,
|
||||
"history": ["<C>●</>최신 기록", "<Y>●</>이전 기록"],
|
||||
"recent_war": null,
|
||||
"aux": "",
|
||||
"ip": "198.51.100.1",
|
||||
"lastconnect": "2026-01-02 03:04:05"
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { Pool as MariaPool } from 'mariadb';
|
||||
import type { Pool as PgPool, PoolClient, QueryResult } from 'pg';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { isLegacyArchiveProfile, migrateGame, resolveLegacyGameOpenedAt } from '../src/game.js';
|
||||
|
||||
const sourceRows = {
|
||||
ng_games: [
|
||||
{
|
||||
id: 1,
|
||||
server_id: 'che_fixture_001',
|
||||
date: new Date('2020-01-01T00:00:00.000Z'),
|
||||
winner_nation: 3,
|
||||
map: 'che',
|
||||
season: 1,
|
||||
scenario: 2,
|
||||
scenario_name: 'fixture',
|
||||
env: JSON.stringify({ opentime: '2020-01-02T00:00:00.000Z', starttime: '2020-01-03T00:00:00.000Z' }),
|
||||
},
|
||||
],
|
||||
ng_old_generals: [
|
||||
{
|
||||
id: 2,
|
||||
server_id: 'che_fixture_001',
|
||||
general_no: 10,
|
||||
owner: 42,
|
||||
name: 'fixture-general',
|
||||
last_yearmonth: 22012,
|
||||
turntime: new Date('2020-02-01T00:00:00.000Z'),
|
||||
data: JSON.stringify({ leader: 80, power: 70, intel: 60, history: 'first<br>second<br>' }),
|
||||
},
|
||||
],
|
||||
} satisfies Record<string, Array<Record<string, unknown>>>;
|
||||
|
||||
const sourcePool = (): MariaPool => {
|
||||
const seen = new Set<string>();
|
||||
return {
|
||||
query: vi.fn(async (sql: string) => {
|
||||
const table = /FROM `([a-z_]+)`/u.exec(sql)?.[1] ?? '';
|
||||
if (seen.has(table)) return [];
|
||||
seen.add(table);
|
||||
return sourceRows[table as keyof typeof sourceRows] ?? [];
|
||||
}),
|
||||
} as unknown as MariaPool;
|
||||
};
|
||||
|
||||
const targetPool = (failPattern?: string) => {
|
||||
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 archive write failure');
|
||||
}
|
||||
if (sql.includes('INSERT INTO "legacy_archive"."import_run"')) {
|
||||
return { rows: [{ id: '77' }], 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('legacy archive game migration', () => {
|
||||
it('uses the official profile allowlist and resolves the best opening timestamp', () => {
|
||||
expect(isLegacyArchiveProfile('che')).toBe(true);
|
||||
expect(isLegacyArchiveProfile('hwe')).toBe(true);
|
||||
expect(isLegacyArchiveProfile('custom')).toBe(false);
|
||||
expect(
|
||||
resolveLegacyGameOpenedAt(
|
||||
{ opentime: '2020-01-02T00:00:00.000Z', starttime: '2020-01-03T00:00:00.000Z' },
|
||||
new Date('2020-01-01T00:00:00.000Z'),
|
||||
'fixture'
|
||||
).toISOString()
|
||||
).toBe('2020-01-02T00:00:00.000Z');
|
||||
expect(
|
||||
resolveLegacyGameOpenedAt(
|
||||
{ starttime: '2020-01-03T00:00:00.000Z' },
|
||||
new Date('2020-01-01T00:00:00.000Z'),
|
||||
'fixture'
|
||||
).toISOString()
|
||||
).toBe('2020-01-03T00:00:00.000Z');
|
||||
expect(resolveLegacyGameOpenedAt({}, new Date('2020-01-01T00:00:00.000Z'), 'fixture').toISOString()).toBe(
|
||||
'2020-01-01T00:00:00.000Z'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps dry-run target-read-only while reporting normalized formats', async () => {
|
||||
const summary = await migrateGame(sourcePool(), null, false, 'che');
|
||||
expect(summary).toMatchObject({
|
||||
apply: false,
|
||||
importRunId: null,
|
||||
counts: { ng_games: 1, ng_old_generals: 1 },
|
||||
sourceFormatSummary: { 'legacy-flat-v0': 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('records a completed import run and writes only archive tables for historical snapshots', async () => {
|
||||
const target = targetPool();
|
||||
const summary = await migrateGame(sourcePool(), target.pool, true, 'che');
|
||||
const sql = target.queries.map((entry) => entry.sql).join('\n');
|
||||
|
||||
expect(summary.importRunId).toBe('77');
|
||||
expect(sql).toContain('INSERT INTO "legacy_archive"."game_history"');
|
||||
expect(sql).toContain('INSERT INTO "legacy_archive"."general"');
|
||||
expect(sql).not.toContain('INSERT INTO "ng_games"');
|
||||
expect(sql).not.toContain('INSERT INTO "ng_old_generals"');
|
||||
expect(sql).toContain(`SET "status" = 'COMPLETED'`);
|
||||
expect(target.queries.some((entry) => entry.sql === 'BEGIN')).toBe(true);
|
||||
expect(target.queries.some((entry) => entry.sql === 'COMMIT')).toBe(true);
|
||||
expect(target.queries.findIndex((entry) => entry.sql.includes(`SET "status" = 'COMPLETED'`))).toBeLessThan(
|
||||
target.queries.findIndex((entry) => entry.sql === 'COMMIT')
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back archive writes and records a failed import run', async () => {
|
||||
const target = targetPool('INSERT INTO "legacy_archive"."general"');
|
||||
await expect(migrateGame(sourcePool(), target.pool, true, 'che')).rejects.toThrow(
|
||||
'synthetic archive write failure'
|
||||
);
|
||||
const sql = target.queries.map((entry) => entry.sql).join('\n');
|
||||
expect(target.queries.some((entry) => entry.sql === 'ROLLBACK')).toBe(true);
|
||||
expect(sql).toContain(`SET "status" = 'FAILED'`);
|
||||
expect(sql).not.toContain(`SET "status" = 'COMPLETED'`);
|
||||
});
|
||||
|
||||
it('rolls back archive writes when completing the import run fails', async () => {
|
||||
const target = targetPool(`SET "status" = 'COMPLETED'`);
|
||||
await expect(migrateGame(sourcePool(), target.pool, true, 'che')).rejects.toThrow(
|
||||
'synthetic archive write failure'
|
||||
);
|
||||
|
||||
expect(target.queries.some((entry) => entry.sql === 'COMMIT')).toBe(false);
|
||||
expect(target.queries.some((entry) => entry.sql === 'ROLLBACK')).toBe(true);
|
||||
expect(target.queries.some((entry) => entry.sql.includes(`SET "status" = 'FAILED'`))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unsupported profile before reading or writing', async () => {
|
||||
await expect(migrateGame(sourcePool(), null, false, 'custom')).rejects.toThrow(
|
||||
'Unsupported legacy archive profile'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { PoolClient } from 'pg';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { upsertRows } from '../src/db.js';
|
||||
import { mapMember, MEMBER_PRESERVED_COLUMNS, preflightMemberConflicts } from '../src/gateway.js';
|
||||
|
||||
const memberRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
NO: 7,
|
||||
GRADE: 1,
|
||||
acl: '{}',
|
||||
penalty: '{}',
|
||||
oauth_info: '{}',
|
||||
oauth_type: 'KAKAO',
|
||||
oauth_id: null,
|
||||
PW: 'a'.repeat(128),
|
||||
salt: 'member-salt',
|
||||
ID: 'LegacyUser',
|
||||
NAME: '레거시유저',
|
||||
EMAIL: 'USER@EXAMPLE.TEST',
|
||||
PICTURE: 'default.jpg',
|
||||
IMGSVR: 0,
|
||||
third_use: 0,
|
||||
token_valid_until: null,
|
||||
delete_after: null,
|
||||
REG_DATE: '2020-01-01 00:00:00',
|
||||
REG_NUM: 0,
|
||||
BLOCK_NUM: 0,
|
||||
BLOCK_DATE: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('legacy gateway member migration', () => {
|
||||
it('marks imported SHA-512 credentials for reset without trusting a missing Kakao ID', () => {
|
||||
const mapped = mapMember(memberRow(), new Date('2026-08-17T00:00:00.000Z'), null);
|
||||
|
||||
expect(mapped).toMatchObject({
|
||||
login_id: 'legacyuser',
|
||||
email: 'user@example.test',
|
||||
password_reset_required: true,
|
||||
oauth_type: 'KAKAO',
|
||||
oauth_id: null,
|
||||
kakao_verified_at: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves target-owned credentials and OAuth state on a repeated member upsert', async () => {
|
||||
const query = vi.fn(async (_sql: string, _values?: unknown[]) => ({ rows: [], rowCount: 0 }));
|
||||
const client = { query } as unknown as PoolClient;
|
||||
|
||||
await upsertRows(
|
||||
client,
|
||||
'app_user',
|
||||
[
|
||||
{
|
||||
id: 'legacy-id',
|
||||
login_id: 'legacy-user',
|
||||
password_hash: 'legacy-hash',
|
||||
oauth_info: '{}',
|
||||
legacy_data: '{}',
|
||||
},
|
||||
],
|
||||
['id'],
|
||||
{ preserveOnConflict: ['password_hash', 'oauth_info'] }
|
||||
);
|
||||
|
||||
const sql = String(query.mock.calls[0]?.[0]);
|
||||
expect(sql).toContain('"login_id" = EXCLUDED."login_id"');
|
||||
expect(sql).toContain('"legacy_data" = EXCLUDED."legacy_data"');
|
||||
expect(sql).not.toContain('"password_hash" = EXCLUDED."password_hash"');
|
||||
expect(sql).not.toContain('"oauth_info" = EXCLUDED."oauth_info"');
|
||||
});
|
||||
|
||||
it('preserves renamed login and display identities along with every live credential field', () => {
|
||||
expect(MEMBER_PRESERVED_COLUMNS).toEqual(
|
||||
expect.arrayContaining([
|
||||
'login_id',
|
||||
'display_name',
|
||||
'password_hash',
|
||||
'password_salt',
|
||||
'password_reset_required',
|
||||
'roles',
|
||||
'sanctions',
|
||||
'oauth_id',
|
||||
'email',
|
||||
'updated_at',
|
||||
'last_login_at',
|
||||
'created_at',
|
||||
])
|
||||
);
|
||||
expect(MEMBER_PRESERVED_COLUMNS).not.toContain('legacy_data');
|
||||
});
|
||||
|
||||
it('rejects a source member when another target account already owns its identity', async () => {
|
||||
const query = vi.fn(async (..._args: unknown[]) => ({
|
||||
rows: [
|
||||
{
|
||||
id: 'another-target-id',
|
||||
login_id: 'legacyuser',
|
||||
display_name: '다른사용자',
|
||||
email: 'other@example.test',
|
||||
},
|
||||
],
|
||||
rowCount: 1,
|
||||
}));
|
||||
const client = { query } as unknown as PoolClient;
|
||||
const mapped = mapMember(memberRow({ oauth_id: 'stable-kakao-id' }), new Date('2026-08-17T00:00:00Z'), null);
|
||||
|
||||
await expect(preflightMemberConflicts(client, [mapped])).rejects.toThrow(
|
||||
'Target account identity collision in legacy member batch'
|
||||
);
|
||||
expect(String(query.mock.calls[0]?.[0])).toContain('FROM "app_user"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user