446 lines
18 KiB
TypeScript
446 lines
18 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
|
|
import type { Pool as MariaPool } from 'mariadb';
|
|
import type { Pool as PgPool, PoolClient } from 'pg';
|
|
|
|
import {
|
|
paginateSource,
|
|
jsonParameter,
|
|
toDate,
|
|
toFloat,
|
|
toNullableDate,
|
|
toNullableString,
|
|
toNumber,
|
|
toStringValue,
|
|
upsertRows,
|
|
withMigrationLock,
|
|
type SourceRow,
|
|
type TargetRow,
|
|
} from './db.js';
|
|
import type { MigrationSummary } from './gateway.js';
|
|
import { legacyUserId } from './identity.js';
|
|
import {
|
|
classifyGameStorage,
|
|
parseInheritanceValue,
|
|
parseJson,
|
|
parseStorageUserId,
|
|
type JsonValue,
|
|
} from './transform.js';
|
|
|
|
const batchSize = 250;
|
|
|
|
const parseNullableJson = (value: unknown, fallback: JsonValue, context: string): JsonValue =>
|
|
value === null || value === undefined ? fallback : parseJson(value, context);
|
|
|
|
const parseJsonOrLegacyEmpty = (value: unknown, context: string): JsonValue =>
|
|
value === '' ? '' : parseJson(value, context);
|
|
|
|
const ownerId = (value: unknown): string | null => {
|
|
if (value === null || value === undefined) {
|
|
return null;
|
|
}
|
|
const memberNo = toNumber(value, 'legacy owner');
|
|
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 migrateSimpleTable = async (
|
|
source: MariaPool,
|
|
target: PoolClient | null,
|
|
sourceTable: string,
|
|
sourceIdColumn: string,
|
|
targetTable: string,
|
|
conflictColumns: readonly string[],
|
|
mapper: (row: SourceRow) => TargetRow,
|
|
counts: Record<string, number>,
|
|
size = batchSize
|
|
): Promise<void> => {
|
|
for await (const rows of paginateSource(source, sourceTable, sourceIdColumn, size)) {
|
|
const mapped = rows.map(mapper);
|
|
if (target) {
|
|
await upsertRows(target, targetTable, mapped, conflictColumns);
|
|
}
|
|
counts[sourceTable] = (counts[sourceTable] ?? 0) + mapped.length;
|
|
}
|
|
};
|
|
|
|
const migrateHall = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> =>
|
|
migrateSimpleTable(
|
|
source,
|
|
target,
|
|
'hall',
|
|
'id',
|
|
'hall',
|
|
['server_id', 'type', 'general_no'],
|
|
(row) => {
|
|
const sourceId = toNumber(row.id, 'hall.id');
|
|
return {
|
|
server_id: toStringValue(row.server_id, `hall.${sourceId}.server_id`),
|
|
season: toNumber(row.season, `hall.${sourceId}.season`),
|
|
scenario: toNumber(row.scenario, `hall.${sourceId}.scenario`),
|
|
general_no: toNumber(row.general_no, `hall.${sourceId}.general_no`),
|
|
type: toStringValue(row.type, `hall.${sourceId}.type`),
|
|
value: toFloat(row.value, `hall.${sourceId}.value`),
|
|
owner: ownerId(row.owner),
|
|
aux: parseJson(row.aux, `hall.${sourceId}.aux`),
|
|
};
|
|
},
|
|
counts
|
|
);
|
|
|
|
const migrateGames = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> =>
|
|
migrateSimpleTable(
|
|
source,
|
|
target,
|
|
'ng_games',
|
|
'id',
|
|
'ng_games',
|
|
['server_id'],
|
|
(row) => {
|
|
const sourceId = toNumber(row.id, 'ng_games.id');
|
|
return {
|
|
server_id: toStringValue(row.server_id, `ng_games.${sourceId}.server_id`),
|
|
date: toDate(row.date, `ng_games.${sourceId}.date`),
|
|
winner_nation:
|
|
row.winner_nation === null
|
|
? null
|
|
: toNumber(row.winner_nation, `ng_games.${sourceId}.winner_nation`),
|
|
map: toNullableString(row.map),
|
|
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`),
|
|
};
|
|
},
|
|
counts
|
|
);
|
|
|
|
const migrateOldGenerals = (
|
|
source: MariaPool,
|
|
target: PoolClient | null,
|
|
counts: Record<string, number>
|
|
): Promise<void> =>
|
|
migrateSimpleTable(
|
|
source,
|
|
target,
|
|
'ng_old_generals',
|
|
'id',
|
|
'ng_old_generals',
|
|
['server_id', 'general_no'],
|
|
(row) => {
|
|
const sourceId = toNumber(row.id, 'ng_old_generals.id');
|
|
return {
|
|
server_id: toStringValue(row.server_id, `ng_old_generals.${sourceId}.server_id`),
|
|
general_no: toNumber(row.general_no, `ng_old_generals.${sourceId}.general_no`),
|
|
owner: ownerId(row.owner),
|
|
name: toStringValue(row.name, `ng_old_generals.${sourceId}.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`),
|
|
};
|
|
},
|
|
counts
|
|
);
|
|
|
|
const migrateOldNations = (
|
|
source: MariaPool,
|
|
target: PoolClient | null,
|
|
counts: Record<string, number>
|
|
): Promise<void> =>
|
|
migrateSimpleTable(
|
|
source,
|
|
target,
|
|
'ng_old_nations',
|
|
'id',
|
|
'ng_old_nations',
|
|
['server_id', 'nation', 'source_id'],
|
|
(row) => {
|
|
const sourceId = toNumber(row.id, 'ng_old_nations.id');
|
|
return {
|
|
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`),
|
|
};
|
|
},
|
|
counts
|
|
);
|
|
|
|
const migrateEmperors = (source: MariaPool, target: PoolClient | null, counts: Record<string, number>): Promise<void> =>
|
|
migrateSimpleTable(
|
|
source,
|
|
target,
|
|
'emperior',
|
|
'no',
|
|
'emperior',
|
|
['legacy_id'],
|
|
(row) => {
|
|
const id = toNumber(row.no, 'emperior.no');
|
|
return {
|
|
legacy_id: id,
|
|
server_id: toNullableString(row.server_id),
|
|
phase: toNullableString(row.phase),
|
|
nation_count: toNullableString(row.nation_count),
|
|
nation_name: toNullableString(row.nation_name),
|
|
nation_hist: toNullableString(row.nation_hist),
|
|
gen_count: toNullableString(row.gen_count),
|
|
personal_hist: toNullableString(row.personal_hist),
|
|
special_hist: toNullableString(row.special_hist),
|
|
name: toNullableString(row.name),
|
|
type: toNullableString(row.type),
|
|
color: toNullableString(row.color),
|
|
year: row.year === null ? null : toNumber(row.year, `emperior.${id}.year`),
|
|
month: row.month === null ? null : toNumber(row.month, `emperior.${id}.month`),
|
|
power: row.power === null ? null : toNumber(row.power, `emperior.${id}.power`),
|
|
gennum: row.gennum === null ? null : toNumber(row.gennum, `emperior.${id}.gennum`),
|
|
citynum: row.citynum === null ? null : toNumber(row.citynum, `emperior.${id}.citynum`),
|
|
pop: toNullableString(row.pop),
|
|
poprate: toNullableString(row.poprate),
|
|
gold: row.gold === null ? null : toNumber(row.gold, `emperior.${id}.gold`),
|
|
rice: row.rice === null ? null : toNumber(row.rice, `emperior.${id}.rice`),
|
|
l12name: toNullableString(row.l12name),
|
|
l12pic: toNullableString(row.l12pic),
|
|
l11name: toNullableString(row.l11name),
|
|
l11pic: toNullableString(row.l11pic),
|
|
l10name: toNullableString(row.l10name),
|
|
l10pic: toNullableString(row.l10pic),
|
|
l9name: toNullableString(row.l9name),
|
|
l9pic: toNullableString(row.l9pic),
|
|
l8name: toNullableString(row.l8name),
|
|
l8pic: toNullableString(row.l8pic),
|
|
l7name: toNullableString(row.l7name),
|
|
l7pic: toNullableString(row.l7pic),
|
|
l6name: toNullableString(row.l6name),
|
|
l6pic: toNullableString(row.l6pic),
|
|
l5name: toNullableString(row.l5name),
|
|
l5pic: toNullableString(row.l5pic),
|
|
tiger: toNullableString(row.tiger),
|
|
eagle: toNullableString(row.eagle),
|
|
gen: toNullableString(row.gen),
|
|
history: parseNullableJson(row.history, [], `emperior.${id}.history`),
|
|
aux: parseNullableJson(row.aux, {}, `emperior.${id}.aux`),
|
|
};
|
|
},
|
|
counts
|
|
);
|
|
|
|
const migrateInheritanceResults = (
|
|
source: MariaPool,
|
|
target: PoolClient | null,
|
|
counts: Record<string, number>
|
|
): Promise<void> =>
|
|
migrateSimpleTable(
|
|
source,
|
|
target,
|
|
'inheritance_result',
|
|
'id',
|
|
'inheritance_result',
|
|
['legacy_id'],
|
|
(row) => {
|
|
const id = toNumber(row.id, 'inheritance_result.id');
|
|
return {
|
|
legacy_id: id,
|
|
server_id: toStringValue(row.server_id, `inheritance_result.${id}.server_id`),
|
|
owner: legacyUserId(toNumber(row.owner, `inheritance_result.${id}.owner`)),
|
|
general_id: toNumber(row.general_id, `inheritance_result.${id}.general_id`),
|
|
year: toNumber(row.year, `inheritance_result.${id}.year`),
|
|
month: toNumber(row.month, `inheritance_result.${id}.month`),
|
|
value: jsonParameter(parseJsonOrLegacyEmpty(row.value, `inheritance_result.${id}.value`)),
|
|
created_at: new Date(0),
|
|
};
|
|
},
|
|
counts
|
|
);
|
|
|
|
const migrateUserRecords = (
|
|
source: MariaPool,
|
|
target: PoolClient | null,
|
|
counts: Record<string, number>
|
|
): Promise<void> =>
|
|
migrateSimpleTable(
|
|
source,
|
|
target,
|
|
'user_record',
|
|
'id',
|
|
'inheritance_log',
|
|
['legacy_id'],
|
|
(row) => {
|
|
const id = toNumber(row.id, 'user_record.id');
|
|
return {
|
|
legacy_id: id,
|
|
user_id: legacyUserId(toNumber(row.user_id, `user_record.${id}.user_id`)),
|
|
server_id: toStringValue(row.server_id, `user_record.${id}.server_id`),
|
|
log_type: toStringValue(row.log_type, `user_record.${id}.log_type`),
|
|
year: toNumber(row.year, `user_record.${id}.year`),
|
|
month: toNumber(row.month, `user_record.${id}.month`),
|
|
text: toStringValue(row.text, `user_record.${id}.text`),
|
|
created_at: toNullableDate(row.date, `user_record.${id}.date`) ?? new Date(0),
|
|
};
|
|
},
|
|
counts
|
|
);
|
|
|
|
const migrateYearbook = async (
|
|
source: MariaPool,
|
|
target: PoolClient | null,
|
|
counts: Record<string, number>
|
|
): Promise<void> => {
|
|
await migrateSimpleTable(
|
|
source,
|
|
target,
|
|
'ng_history',
|
|
'no',
|
|
'yearbook_history',
|
|
['profile_name', 'year', 'month', 'source_id'],
|
|
(row) => {
|
|
const id = toNumber(row.no, 'ng_history.no');
|
|
const mapped: TargetRow = {
|
|
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),
|
|
};
|
|
mapped.hash = hashYearbook(mapped);
|
|
return mapped;
|
|
},
|
|
counts,
|
|
25
|
|
);
|
|
};
|
|
|
|
const migrateStorage = async (
|
|
source: MariaPool,
|
|
target: PoolClient | null,
|
|
counts: Record<string, number>
|
|
): Promise<void> => {
|
|
for await (const rows of paginateSource(source, 'storage', 'id', batchSize)) {
|
|
const archives: TargetRow[] = [];
|
|
const points: TargetRow[] = [];
|
|
const userStates: TargetRow[] = [];
|
|
for (const row of rows) {
|
|
const sourceId = toNumber(row.id, 'storage.id');
|
|
const namespace = toStringValue(row.namespace, `storage.${sourceId}.namespace`);
|
|
const key = toStringValue(row.key, `storage.${sourceId}.key`);
|
|
const value = parseJson(row.value, `storage.${sourceId}.value`);
|
|
const scope = classifyGameStorage(namespace, key);
|
|
counts.storage_inspected = (counts.storage_inspected ?? 0) + 1;
|
|
if (scope === 'season-state') {
|
|
counts.storage_season_excluded = (counts.storage_season_excluded ?? 0) + 1;
|
|
continue;
|
|
}
|
|
archives.push({ source_id: sourceId, namespace, key, value: jsonParameter(value), scope });
|
|
|
|
const inheritanceUserId = parseStorageUserId(namespace, 'inheritance');
|
|
if (inheritanceUserId) {
|
|
points.push({
|
|
user_id: inheritanceUserId,
|
|
key,
|
|
value: parseInheritanceValue(value, `storage.${sourceId}.value`),
|
|
aux: {
|
|
legacyNamespace: namespace,
|
|
legacySourceId: sourceId,
|
|
legacyAux: Array.isArray(value) ? (value[1] ?? null) : null,
|
|
},
|
|
updated_at: new Date(0),
|
|
});
|
|
}
|
|
const stateUserId = parseStorageUserId(namespace, 'user');
|
|
if (stateUserId && key === 'last_stat_reset') {
|
|
userStates.push({
|
|
user_id: stateUserId,
|
|
meta: { lastStatReset: value, legacySourceId: sourceId },
|
|
updated_at: new Date(0),
|
|
});
|
|
}
|
|
}
|
|
if (target) {
|
|
await upsertRows(target, 'legacy_game_storage', archives, ['source_id']);
|
|
await upsertRows(target, 'inheritance_point', points, ['user_id', 'key']);
|
|
await upsertRows(target, 'inheritance_user_state', userStates, ['user_id']);
|
|
}
|
|
counts.storage_archived = (counts.storage_archived ?? 0) + archives.length;
|
|
counts.inheritance_point = (counts.inheritance_point ?? 0) + points.length;
|
|
counts.inheritance_user_state = (counts.inheritance_user_state ?? 0) + userStates.length;
|
|
}
|
|
};
|
|
|
|
export const migrateGame = async (
|
|
source: MariaPool,
|
|
targetPool: PgPool | null,
|
|
apply: boolean,
|
|
profile: string
|
|
): Promise<MigrationSummary> => {
|
|
const counts: Record<string, number> = {};
|
|
const excluded = {
|
|
general: 'Current-season actor state is intentionally not transferred.',
|
|
city: 'Current-season world state is intentionally not transferred.',
|
|
nation: 'Current-season nation state is intentionally not transferred.',
|
|
general_turn: 'Current-season command queue.',
|
|
general_access_log: 'Current-season access counters.',
|
|
nation_turn: 'Current-season nation command queue.',
|
|
nation_env: 'Current-season nation KV state.',
|
|
board: 'Current-season nation board.',
|
|
comment: 'Current-season nation board comments.',
|
|
diplomacy: 'Current-season diplomacy state.',
|
|
event: 'Current-season scheduled events.',
|
|
message: 'Current-season mailboxes.',
|
|
rank_data: 'Current-season ranking counters.',
|
|
statistic: 'Current-season statistics used to build permanent dynasty records.',
|
|
world_history: 'Current-season history; completed-month snapshots come from ng_history.',
|
|
general_record: 'Current-season general logs.',
|
|
ng_auction: 'Current-season auction.',
|
|
ng_auction_bid: 'Current-season auction bids.',
|
|
ng_betting: 'Current-season betting.',
|
|
ng_diplomacy: 'Current-season diplomacy letters.',
|
|
plock: 'Legacy process lock.',
|
|
reserved_open: 'Legacy opening schedule.',
|
|
select_npc_token: 'Ephemeral selection token.',
|
|
select_pool: 'Current-season selection pool.',
|
|
tournament: 'Current-season tournament.',
|
|
troop: 'Current-season troop state.',
|
|
vote: 'Current-season vote.',
|
|
vote_comment: 'Current-season vote comments.',
|
|
'storage:season-state': 'Only inheritance_* and user_* long-lived namespaces are archived or projected.',
|
|
};
|
|
const client = apply && targetPool ? await targetPool.connect() : 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);
|
|
await migrateInheritanceResults(source, client, counts);
|
|
await migrateUserRecords(source, client, counts);
|
|
await migrateStorage(source, client, counts);
|
|
await migrateYearbook(source, client, counts);
|
|
};
|
|
if (client) {
|
|
await withMigrationLock(client, `sammo-legacy-game-v1:${profile}`, run);
|
|
} else {
|
|
await run();
|
|
}
|
|
} finally {
|
|
client?.release();
|
|
}
|
|
return { command: 'game', apply, counts, excluded };
|
|
};
|