410 lines
16 KiB
TypeScript
410 lines
16 KiB
TypeScript
import type { Pool as MariaPool } from 'mariadb';
|
|
import type { Pool as PgPool, PoolClient } from 'pg';
|
|
|
|
import { legacyUserId } from './identity.js';
|
|
import {
|
|
paginateSource,
|
|
jsonParameter,
|
|
querySource,
|
|
sourceMaxId,
|
|
toBigInt,
|
|
toDate,
|
|
toNullableDate,
|
|
toNullableString,
|
|
toNumber,
|
|
toStringValue,
|
|
upsertRows,
|
|
withMigrationLock,
|
|
type SourceRow,
|
|
type TargetRow,
|
|
} from './db.js';
|
|
import {
|
|
defaultExecutionOptions,
|
|
loadCheckpoint,
|
|
requireIncrementalCheckpoint,
|
|
saveCheckpoint,
|
|
validateSourceIdentity,
|
|
type MigrationExecutionOptions,
|
|
type MigrationProgress,
|
|
} from './incremental.js';
|
|
import { mapLegacyRoles, mapLegacySanctions, parseJson, type JsonValue } from './transform.js';
|
|
|
|
export interface MigrationSummary {
|
|
command: 'gateway' | 'game';
|
|
apply: boolean;
|
|
counts: Record<string, number>;
|
|
excluded: Record<string, string>;
|
|
importRunId?: string | null;
|
|
sourceFormatSummary?: Record<string, number>;
|
|
mode?: 'full' | 'incremental';
|
|
sourceKey?: string;
|
|
progress?: MigrationProgress;
|
|
}
|
|
|
|
const batchSize = 500;
|
|
|
|
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,
|
|
acl,
|
|
penalty,
|
|
tokenValidUntil: toNullableString(row.token_valid_until),
|
|
regNum: toNumber(row.REG_NUM, `member.${memberNo}.REG_NUM`),
|
|
blockNum: toNumber(row.BLOCK_NUM, `member.${memberNo}.BLOCK_NUM`),
|
|
blockDate: toNullableString(row.BLOCK_DATE),
|
|
};
|
|
return {
|
|
id: legacyUserId(memberNo),
|
|
login_id: toStringValue(row.ID, `member.${memberNo}.ID`).toLowerCase(),
|
|
display_name: toStringValue(row.NAME, `member.${memberNo}.NAME`),
|
|
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: oauthId,
|
|
email: toNullableString(row.EMAIL)?.toLowerCase() ?? null,
|
|
oauth_info: jsonParameter(oauthInfo),
|
|
picture: toNullableString(row.PICTURE) ?? 'default.jpg',
|
|
image_server: toNumber(row.IMGSVR ?? 0, `member.${memberNo}.IMGSVR`),
|
|
icon_updated_at: 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' && oauthId ? migratedAt : null,
|
|
kakao_talk_verified_until:
|
|
oauthType === 'KAKAO' && oauthId
|
|
? toNullableDate(row.token_valid_until, `member.${memberNo}.token_valid_until`)
|
|
: null,
|
|
kakao_grace_started_at: migratedAt,
|
|
delete_after: toNullableDate(row.delete_after, `member.${memberNo}.delete_after`),
|
|
created_at: toDate(row.REG_DATE, `member.${memberNo}.REG_DATE`),
|
|
updated_at: migratedAt,
|
|
last_login_at: lastLoginAt,
|
|
legacy_data: jsonParameter(legacyData),
|
|
};
|
|
};
|
|
|
|
const loadLastLogins = async (source: MariaPool): Promise<Map<number, Date>> => {
|
|
const rows = await querySource(
|
|
source,
|
|
"SELECT member_no, MAX(`date`) AS last_login_at FROM member_log WHERE action_type = 'login' GROUP BY member_no"
|
|
);
|
|
return new Map(
|
|
rows.map((row) => {
|
|
const memberNo = toNumber(row.member_no, 'member_log.member_no');
|
|
return [memberNo, toDate(row.last_login_at, `member_log.${memberNo}.last_login_at`)];
|
|
})
|
|
);
|
|
};
|
|
|
|
const processMembers = async (
|
|
source: MariaPool,
|
|
target: PoolClient | null,
|
|
apply: boolean,
|
|
migratedAt: Date,
|
|
counts: Record<string, number>
|
|
): Promise<void> => {
|
|
const lastLogins = await loadLastLogins(source);
|
|
for await (const rows of paginateSource(source, 'member', 'NO', batchSize)) {
|
|
const mapped = rows.map((row) => {
|
|
const memberNo = toNumber(row.NO, 'member.NO');
|
|
return mapMember(row, migratedAt, lastLogins.get(memberNo) ?? null);
|
|
});
|
|
if (target) {
|
|
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;
|
|
}
|
|
};
|
|
|
|
const processMemberLogs = async (
|
|
source: MariaPool,
|
|
target: PoolClient | null,
|
|
counts: Record<string, number>,
|
|
afterId = -1n
|
|
): Promise<void> => {
|
|
for await (const rows of paginateSource(source, 'member_log', 'id', batchSize, afterId)) {
|
|
const mapped = rows.map<TargetRow>((row) => {
|
|
const id = toBigInt(row.id, 'member_log.id');
|
|
const memberNo = toNumber(row.member_no, `member_log.${id}.member_no`);
|
|
return {
|
|
id: id.toString(),
|
|
member_no: memberNo,
|
|
user_id: legacyUserId(memberNo),
|
|
date: toDate(row.date, `member_log.${id}.date`),
|
|
action_type: toStringValue(row.action_type, `member_log.${id}.action_type`),
|
|
action: row.action === null ? null : jsonParameter(parseJson(row.action, `member_log.${id}.action`)),
|
|
};
|
|
});
|
|
if (target) {
|
|
await upsertRows(target, 'legacy_member_log', mapped, ['id']);
|
|
}
|
|
counts.member_log = (counts.member_log ?? 0) + mapped.length;
|
|
}
|
|
};
|
|
|
|
const processBannedMembers = async (
|
|
source: MariaPool,
|
|
target: PoolClient | null,
|
|
counts: Record<string, number>
|
|
): Promise<void> => {
|
|
const rows = await querySource(source, 'SELECT * FROM banned_member ORDER BY no');
|
|
const mapped = rows.map<TargetRow>((row) => ({
|
|
no: toNumber(row.no, 'banned_member.no'),
|
|
hashed_email: toStringValue(row.hashed_email, 'banned_member.hashed_email'),
|
|
info: toNullableString(row.info),
|
|
}));
|
|
if (target) {
|
|
await upsertRows(target, 'legacy_banned_member', mapped, ['no']);
|
|
}
|
|
counts.banned_member = mapped.length;
|
|
};
|
|
|
|
const processRootKeyValues = async (
|
|
source: MariaPool,
|
|
target: PoolClient | null,
|
|
counts: Record<string, number>
|
|
): Promise<void> => {
|
|
const storage = await querySource(source, 'SELECT * FROM storage ORDER BY id');
|
|
const rows: TargetRow[] = storage.map((row) => ({
|
|
source_table: 'storage',
|
|
namespace: toStringValue(row.namespace, 'storage.namespace'),
|
|
key: toStringValue(row.key, 'storage.key'),
|
|
value: jsonParameter(parseJson(row.value, `storage.${String(row.id)}.value`)),
|
|
}));
|
|
if (target) {
|
|
for (let offset = 0; offset < rows.length; offset += batchSize) {
|
|
await upsertRows(target, 'legacy_root_key_value', rows.slice(offset, offset + batchSize), [
|
|
'source_table',
|
|
'namespace',
|
|
'key',
|
|
]);
|
|
}
|
|
}
|
|
counts.root_key_value = rows.length;
|
|
};
|
|
|
|
const processSystem = async (
|
|
source: MariaPool,
|
|
target: PoolClient | null,
|
|
counts: Record<string, number>
|
|
): Promise<void> => {
|
|
const rows = await querySource(source, 'SELECT * FROM system ORDER BY NO');
|
|
const mapped = rows.map<TargetRow>((row) => ({
|
|
no: toNumber(row.NO, 'system.NO'),
|
|
registration_enabled: row.REG === 'Y',
|
|
login_enabled: row.LOGIN === 'Y',
|
|
notice: toNullableString(row.NOTICE) ?? '',
|
|
created_at: toNullableDate(row.CRT_DATE, 'system.CRT_DATE'),
|
|
updated_at: toNullableDate(row.MDF_DATE, 'system.MDF_DATE'),
|
|
}));
|
|
if (target) {
|
|
await upsertRows(target, 'system', mapped, ['no']);
|
|
}
|
|
counts.system = mapped.length;
|
|
};
|
|
|
|
export const migrateGateway = async (
|
|
source: MariaPool,
|
|
targetPool: PgPool | null,
|
|
apply: boolean,
|
|
migratedAt: Date,
|
|
execution: MigrationExecutionOptions = defaultExecutionOptions('legacy-root')
|
|
): Promise<MigrationSummary> => {
|
|
validateSourceIdentity(execution.source);
|
|
if (execution.mode === 'incremental' && !targetPool) {
|
|
throw new Error('Incremental gateway migration requires the target database to read checkpoints');
|
|
}
|
|
const counts: Record<string, number> = {};
|
|
const progress: MigrationProgress = {};
|
|
const excluded = {
|
|
login_token:
|
|
'Legacy bearer tokens, IP addresses, and expired sessions are not valid in the Redis session model.',
|
|
};
|
|
const client = targetPool ? await targetPool.connect() : null;
|
|
let importRunId: string | null = null;
|
|
try {
|
|
const run = async (runId: string | null): Promise<void> => {
|
|
await processMembers(source, client, apply, migratedAt, counts);
|
|
progress.member = {
|
|
strategy: 'rescan',
|
|
startAfterId: null,
|
|
endAtId: null,
|
|
processed: counts.member ?? 0,
|
|
};
|
|
|
|
const maxMemberLogId = (await sourceMaxId(source, 'member_log', 'id')) ?? -1n;
|
|
const checkpoint =
|
|
execution.mode === 'incremental' && client
|
|
? await loadCheckpoint(
|
|
client,
|
|
{ tableSql: '"legacy_import_checkpoint"' },
|
|
execution.source.key,
|
|
'member_log'
|
|
)
|
|
: null;
|
|
const memberLogAfter =
|
|
execution.mode === 'incremental'
|
|
? requireIncrementalCheckpoint(checkpoint, execution.source, 'member_log')
|
|
: -1n;
|
|
if (maxMemberLogId < memberLogAfter) {
|
|
throw new Error('Legacy source member_log maximum ID regressed; run a reviewed full migration');
|
|
}
|
|
await processMemberLogs(source, apply ? client : null, counts, memberLogAfter);
|
|
progress.member_log = {
|
|
strategy: 'append',
|
|
startAfterId: memberLogAfter < 0n ? null : memberLogAfter.toString(),
|
|
endAtId: maxMemberLogId < 0n ? null : maxMemberLogId.toString(),
|
|
processed: counts.member_log ?? 0,
|
|
};
|
|
await processBannedMembers(source, apply ? client : null, counts);
|
|
await processRootKeyValues(source, apply ? client : null, counts);
|
|
await processSystem(source, apply ? client : null, counts);
|
|
for (const table of ['banned_member', 'root_key_value', 'system'] as const) {
|
|
progress[table] = {
|
|
strategy: 'rescan',
|
|
startAfterId: null,
|
|
endAtId: null,
|
|
processed: counts[table] ?? 0,
|
|
};
|
|
}
|
|
if (apply && client && runId) {
|
|
await saveCheckpoint(
|
|
client,
|
|
{ tableSql: '"legacy_import_checkpoint"' },
|
|
execution.source,
|
|
'member_log',
|
|
maxMemberLogId,
|
|
runId
|
|
);
|
|
}
|
|
};
|
|
if (client && apply) {
|
|
await withMigrationLock(client, `sammo-legacy-gateway-v2:${execution.source.key}`, async () => {
|
|
const created = await client.query<{ id: string }>(
|
|
`INSERT INTO "legacy_import_run"
|
|
("source_key", "source_fingerprint", "mode", "status")
|
|
VALUES ($1, $2, $3, 'RUNNING') RETURNING "id"`,
|
|
[execution.source.key, execution.source.fingerprint, execution.mode]
|
|
);
|
|
importRunId = created.rows[0]?.id ?? null;
|
|
if (!importRunId) throw new Error('Failed to create legacy gateway import run');
|
|
await client.query('BEGIN');
|
|
try {
|
|
await run(importRunId);
|
|
await client.query(
|
|
`UPDATE "legacy_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)]
|
|
);
|
|
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_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;
|
|
}
|
|
});
|
|
} else {
|
|
await run(null);
|
|
}
|
|
} finally {
|
|
client?.release();
|
|
}
|
|
return {
|
|
command: 'gateway',
|
|
apply,
|
|
counts,
|
|
excluded,
|
|
importRunId,
|
|
mode: execution.mode,
|
|
sourceKey: execution.source.key,
|
|
progress,
|
|
};
|
|
};
|