feat: migrate legacy long-lived database records
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# Legacy DB migration CLI
|
||||
|
||||
This package migrates the long-lived parts of a restored ref MariaDB database
|
||||
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.
|
||||
|
||||
## Source restore
|
||||
|
||||
Restore each compressed table dump into a private MariaDB database before
|
||||
running this tool. Do not expose that database on a public interface. The dump
|
||||
directory is intentionally Git-ignored.
|
||||
|
||||
```sh
|
||||
gzip -cd /path/to/db_dumps/root/member.sql.gz | mariadb root_dump
|
||||
gzip -cd /path/to/db_dumps/che/ng_games.sql.gz | mariadb che_dump
|
||||
```
|
||||
|
||||
Restore all tables defined by ref even though the CLI intentionally projects
|
||||
only long-lived tables. This lets the dry-run verify the source inventory and
|
||||
keeps the original dump as the recovery source.
|
||||
|
||||
Database URLs belong in a Git-ignored environment file or injected process
|
||||
environment. They are deliberately not accepted as command-line flags.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
LEGACY_ROOT_DATABASE_URL=... pnpm --filter @sammo-ts/legacy-db-migration migrate gateway
|
||||
LEGACY_GAME_DATABASE_URL=... pnpm --filter @sammo-ts/legacy-db-migration migrate game --profile che
|
||||
```
|
||||
|
||||
After reviewing the JSON counts and excluded-table reasons, add
|
||||
`GATEWAY_DATABASE_URL` or `GAME_DATABASE_URL` and repeat with `--apply`.
|
||||
|
||||
Kakao members retain their OAuth ID, email, and OAuth metadata.
|
||||
`kakao_verified_at` and `kakao_grace_started_at` are set to the migration time,
|
||||
which renews verification at 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.
|
||||
|
||||
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
|
||||
are not silently imported.
|
||||
|
||||
For an isolated test account, put a temporary password in a mode-0600 file and
|
||||
run:
|
||||
|
||||
```sh
|
||||
GATEWAY_DATABASE_URL=... pnpm --filter @sammo-ts/legacy-db-migration migrate \
|
||||
reset-password --login-id test-user --password-file /secure/path/password --apply
|
||||
```
|
||||
|
||||
The password value is never accepted on the command line or printed.
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@sammo-ts/legacy-db-migration",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"sammo-migrate-legacy-db": "./dist/cli.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown src/cli.ts --no-config --format esm --out-dir dist --dts --sourcemap --target node22 --platform node",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"typecheck": "tsc -b",
|
||||
"migrate": "tsx src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"mariadb": "3.5.3",
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/pg": "^8.15.6",
|
||||
"tsdown": "^0.22.14",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "6.0.2",
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import { createMariaPool, createPostgresPool } from './db.js';
|
||||
import { migrateGame } from './game.js';
|
||||
import { migrateGateway } from './gateway.js';
|
||||
import { hashPasswordForReset } from './password.js';
|
||||
|
||||
type Command = 'gateway' | 'game' | 'reset-password';
|
||||
|
||||
interface CliOptions {
|
||||
command: Command;
|
||||
apply: boolean;
|
||||
profile?: string;
|
||||
loginId?: string;
|
||||
passwordFile?: string;
|
||||
}
|
||||
|
||||
const usage = `Usage:
|
||||
pnpm --filter @sammo-ts/legacy-db-migration migrate gateway [--apply]
|
||||
pnpm --filter @sammo-ts/legacy-db-migration migrate game --profile <profile> [--apply]
|
||||
pnpm --filter @sammo-ts/legacy-db-migration migrate reset-password --login-id <id> --password-file <path> --apply
|
||||
|
||||
Environment:
|
||||
LEGACY_ROOT_DATABASE_URL MariaDB URL for the restored root dump
|
||||
LEGACY_GAME_DATABASE_URL MariaDB URL for one restored game-profile dump
|
||||
GATEWAY_DATABASE_URL target PostgreSQL URL for gateway
|
||||
GAME_DATABASE_URL target PostgreSQL URL for the selected game profile
|
||||
|
||||
Dry-run is the default. Source and target URLs are accepted only through the environment so
|
||||
credentials are not exposed in the process list.`;
|
||||
|
||||
const parseArguments = (argv: readonly string[]): CliOptions => {
|
||||
const command = argv[0];
|
||||
if (command !== 'gateway' && command !== 'game' && command !== 'reset-password') {
|
||||
throw new Error(usage);
|
||||
}
|
||||
const options: CliOptions = { command, apply: false };
|
||||
for (let index = 1; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === '--apply') {
|
||||
options.apply = true;
|
||||
continue;
|
||||
}
|
||||
const next = argv[index + 1];
|
||||
if (!next || next.startsWith('--')) {
|
||||
throw new Error(`Missing value for ${argument}\n\n${usage}`);
|
||||
}
|
||||
if (argument === '--profile') {
|
||||
options.profile = next;
|
||||
} else if (argument === '--login-id') {
|
||||
options.loginId = next;
|
||||
} else if (argument === '--password-file') {
|
||||
options.passwordFile = next;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${argument}\n\n${usage}`);
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
const requireEnvironment = (name: string): string => {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const resetPassword = async (options: CliOptions): Promise<Record<string, unknown>> => {
|
||||
if (!options.apply) {
|
||||
throw new Error('reset-password requires --apply');
|
||||
}
|
||||
if (!options.loginId || !options.passwordFile) {
|
||||
throw new Error(`reset-password requires --login-id and --password-file\n\n${usage}`);
|
||||
}
|
||||
const passwordPath = path.resolve(options.passwordFile);
|
||||
const passwordStat = await stat(passwordPath);
|
||||
if ((passwordStat.mode & 0o077) !== 0) {
|
||||
throw new Error('Password file must not be readable or writable by group/other (expected mode 0600)');
|
||||
}
|
||||
const password = (await readFile(passwordPath, 'utf8')).replace(/\r?\n$/, '');
|
||||
if (!password) {
|
||||
throw new Error('Password file is empty');
|
||||
}
|
||||
|
||||
const pool = createPostgresPool(requireEnvironment('GATEWAY_DATABASE_URL'));
|
||||
try {
|
||||
const existing = await pool.query<{ id: string }>('SELECT "id" FROM "app_user" WHERE "login_id" = $1', [
|
||||
options.loginId.toLowerCase(),
|
||||
]);
|
||||
if (existing.rowCount !== 1) {
|
||||
throw new Error('Exactly one migrated account must match --login-id');
|
||||
}
|
||||
const hashed = await hashPasswordForReset(password);
|
||||
await pool.query(
|
||||
`UPDATE "app_user"
|
||||
SET "password_hash" = $1, "password_salt" = $2, "updated_at" = CURRENT_TIMESTAMP
|
||||
WHERE "id" = $3`,
|
||||
[hashed.hash, hashed.salt, existing.rows[0]!.id]
|
||||
);
|
||||
return { command: 'reset-password', updated: 1, loginId: options.loginId.toLowerCase() };
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
};
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
const options = parseArguments(process.argv.slice(2));
|
||||
if (options.command === 'reset-password') {
|
||||
console.log(JSON.stringify(await resetPassword(options), null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
const summary = await migrateGateway(source, target, options.apply, migratedAt);
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
} finally {
|
||||
await source.end();
|
||||
await target?.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.profile || !/^[a-z][a-z0-9_-]{1,31}$/.test(options.profile)) {
|
||||
throw new Error(`game requires a safe --profile value\n\n${usage}`);
|
||||
}
|
||||
const source = createMariaPool(requireEnvironment('LEGACY_GAME_DATABASE_URL'));
|
||||
const target = options.apply ? createPostgresPool(requireEnvironment('GAME_DATABASE_URL')) : null;
|
||||
try {
|
||||
const summary = await migrateGame(source, target, options.apply, options.profile);
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
} finally {
|
||||
await source.end();
|
||||
await target?.end();
|
||||
}
|
||||
};
|
||||
|
||||
run().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[legacy-db-migration] ${message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import mariadb, { type Pool as MariaPool } from 'mariadb';
|
||||
import pg, { type PoolClient } from 'pg';
|
||||
|
||||
export type SourceRow = Record<string, unknown>;
|
||||
export type TargetRow = Record<string, unknown>;
|
||||
|
||||
class JsonParameter {
|
||||
readonly value: unknown;
|
||||
|
||||
constructor(value: unknown) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
export const jsonParameter = (value: unknown): JsonParameter => new JsonParameter(value);
|
||||
|
||||
const IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
|
||||
const MARIA_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
const quoteIdentifier = (value: string): string => {
|
||||
if (!IDENTIFIER.test(value)) {
|
||||
throw new Error(`Unsafe SQL identifier: ${value}`);
|
||||
}
|
||||
return `"${value}"`;
|
||||
};
|
||||
|
||||
export const createMariaPool = (uri: string): MariaPool => mariadb.createPool(uri);
|
||||
|
||||
export const createPostgresPool = (connectionString: string): pg.Pool =>
|
||||
new pg.Pool({
|
||||
connectionString,
|
||||
max: 2,
|
||||
application_name: 'sammo-legacy-db-migration',
|
||||
});
|
||||
|
||||
const isSourceRow = (value: unknown): value is SourceRow =>
|
||||
value !== null && !Array.isArray(value) && typeof value === 'object';
|
||||
|
||||
export const querySource = async (
|
||||
pool: MariaPool,
|
||||
sql: string,
|
||||
parameters: readonly unknown[] = []
|
||||
): Promise<SourceRow[]> => {
|
||||
const result: unknown = await pool.query(sql, [...parameters]);
|
||||
if (!Array.isArray(result)) {
|
||||
throw new Error('MariaDB query did not return rows');
|
||||
}
|
||||
return result.filter(isSourceRow);
|
||||
};
|
||||
|
||||
export const paginateSource = async function* (
|
||||
pool: MariaPool,
|
||||
table: string,
|
||||
idColumn: string,
|
||||
batchSize: number
|
||||
): AsyncGenerator<SourceRow[]> {
|
||||
if (!MARIA_IDENTIFIER.test(table) || !MARIA_IDENTIFIER.test(idColumn)) {
|
||||
throw new Error('Unsafe MariaDB table or ID column');
|
||||
}
|
||||
let lastId = -1n;
|
||||
for (;;) {
|
||||
const rows = await querySource(
|
||||
pool,
|
||||
`SELECT * FROM \`${table}\` WHERE \`${idColumn}\` > ? ORDER BY \`${idColumn}\` ASC LIMIT ?`,
|
||||
[lastId.toString(), batchSize]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
yield rows;
|
||||
lastId = toBigInt(rows.at(-1)?.[idColumn], `${table}.${idColumn}`);
|
||||
}
|
||||
};
|
||||
|
||||
const targetValue = (value: unknown): unknown => {
|
||||
if (value instanceof JsonParameter) {
|
||||
return JSON.stringify(value.value);
|
||||
}
|
||||
if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const upsertRows = async (
|
||||
client: PoolClient,
|
||||
table: string,
|
||||
rows: readonly TargetRow[],
|
||||
conflictColumns: readonly string[]
|
||||
): Promise<void> => {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
const columns = Object.keys(rows[0]!);
|
||||
if (
|
||||
columns.length === 0 ||
|
||||
rows.some((row) => columns.some((column) => !(column in row))) ||
|
||||
conflictColumns.some((column) => !columns.includes(column))
|
||||
) {
|
||||
throw new Error(`Inconsistent row shape for ${table}`);
|
||||
}
|
||||
|
||||
const values: unknown[] = [];
|
||||
const tuples = rows.map((row, rowIndex) => {
|
||||
const placeholders = columns.map((column, columnIndex) => {
|
||||
values.push(targetValue(row[column]));
|
||||
return `$${rowIndex * columns.length + columnIndex + 1}`;
|
||||
});
|
||||
return `(${placeholders.join(', ')})`;
|
||||
});
|
||||
const updates = columns
|
||||
.filter((column) => !conflictColumns.includes(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(', ')})
|
||||
VALUES ${tuples.join(', ')}
|
||||
ON CONFLICT (${conflictColumns.map(quoteIdentifier).join(', ')}) ${conflictAction}`,
|
||||
values
|
||||
);
|
||||
};
|
||||
|
||||
export const withMigrationLock = async <T>(
|
||||
client: PoolClient,
|
||||
lockName: string,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> => {
|
||||
await client.query('SELECT pg_advisory_lock(hashtext($1))', [lockName]);
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
await client.query('SELECT pg_advisory_unlock(hashtext($1))', [lockName]);
|
||||
}
|
||||
};
|
||||
|
||||
export const toNumber = (value: unknown, context: string): number => {
|
||||
const number = typeof value === 'number' ? value : typeof value === 'bigint' ? Number(value) : Number(value);
|
||||
if (!Number.isSafeInteger(number)) {
|
||||
throw new Error(`${context}: expected a safe integer`);
|
||||
}
|
||||
return number;
|
||||
};
|
||||
|
||||
export const toBigInt = (value: unknown, context: string): bigint => {
|
||||
try {
|
||||
return BigInt(String(value));
|
||||
} catch (error) {
|
||||
throw new Error(`${context}: expected an integer`, { cause: error });
|
||||
}
|
||||
};
|
||||
|
||||
export const toFloat = (value: unknown, context: string): number => {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) {
|
||||
throw new Error(`${context}: expected a finite number`);
|
||||
}
|
||||
return number;
|
||||
};
|
||||
|
||||
export const toStringValue = (value: unknown, context: string): string => {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${context}: expected text`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const toNullableString = (value: unknown): string | null =>
|
||||
value === null || value === undefined ? null : String(value);
|
||||
|
||||
export const toDate = (value: unknown, context: string): Date => {
|
||||
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
||||
return value;
|
||||
}
|
||||
const text = String(value);
|
||||
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(text)
|
||||
? `${text.replace(' ', 'T')}Z`
|
||||
: text;
|
||||
const date = new Date(normalized);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new Error(`${context}: invalid date`);
|
||||
}
|
||||
return date;
|
||||
};
|
||||
|
||||
export const toNullableDate = (value: unknown, context: string): Date | null =>
|
||||
value === null || value === undefined ? null : toDate(value, context);
|
||||
@@ -0,0 +1,445 @@
|
||||
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 };
|
||||
};
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { Pool as MariaPool } from 'mariadb';
|
||||
import type { Pool as PgPool, PoolClient } from 'pg';
|
||||
|
||||
import { legacyUserId } from './identity.js';
|
||||
import {
|
||||
paginateSource,
|
||||
jsonParameter,
|
||||
querySource,
|
||||
toBigInt,
|
||||
toDate,
|
||||
toNullableDate,
|
||||
toNullableString,
|
||||
toNumber,
|
||||
toStringValue,
|
||||
upsertRows,
|
||||
withMigrationLock,
|
||||
type SourceRow,
|
||||
type TargetRow,
|
||||
} from './db.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>;
|
||||
}
|
||||
|
||||
const batchSize = 500;
|
||||
|
||||
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 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: toStringValue(row.PW, `member.${memberNo}.PW`),
|
||||
password_salt: toStringValue(row.salt, `member.${memberNo}.salt`),
|
||||
roles: jsonParameter(mapLegacyRoles(grade, acl)),
|
||||
sanctions: jsonParameter(mapLegacySanctions(grade, penalty)),
|
||||
oauth_type: oauthType,
|
||||
oauth_id: toNullableString(row.oauth_id),
|
||||
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' ? migratedAt : 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,
|
||||
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 upsertRows(target, 'app_user', mapped, ['id']);
|
||||
}
|
||||
counts.member = (counts.member ?? 0) + mapped.length;
|
||||
}
|
||||
};
|
||||
|
||||
const processMemberLogs = async (
|
||||
source: MariaPool,
|
||||
target: PoolClient | null,
|
||||
counts: Record<string, number>
|
||||
): Promise<void> => {
|
||||
for await (const rows of paginateSource(source, 'member_log', 'id', batchSize)) {
|
||||
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
|
||||
): Promise<MigrationSummary> => {
|
||||
const counts: Record<string, number> = {};
|
||||
const excluded = {
|
||||
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;
|
||||
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);
|
||||
};
|
||||
if (client) {
|
||||
await withMigrationLock(client, 'sammo-legacy-gateway-v1', run);
|
||||
} else {
|
||||
await run();
|
||||
}
|
||||
} finally {
|
||||
client?.release();
|
||||
}
|
||||
return { command: 'gateway', apply, counts, excluded };
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const LEGACY_USER_NAMESPACE = 'sammo-ts:legacy-root-member:v1';
|
||||
|
||||
export const legacyUserId = (memberNo: number): string => {
|
||||
if (!Number.isSafeInteger(memberNo) || memberNo <= 0) {
|
||||
throw new Error(`Legacy member number must be a positive safe integer: ${memberNo}`);
|
||||
}
|
||||
|
||||
const bytes = createHash('sha256').update(`${LEGACY_USER_NAMESPACE}:${memberNo}`).digest().subarray(0, 16);
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x50;
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
const hex = bytes.toString('hex');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { argon2, randomBytes } from 'node:crypto';
|
||||
|
||||
const MEMORY_KIB = 19 * 1024;
|
||||
const PASSES = 2;
|
||||
const PARALLELISM = 1;
|
||||
const TAG_LENGTH = 32;
|
||||
const PREFIX = `$argon2id$v=19$m=${MEMORY_KIB},t=${PASSES},p=${PARALLELISM}$`;
|
||||
|
||||
export const hashPasswordForReset = async (password: string): Promise<{ hash: string; salt: string }> => {
|
||||
const salt = randomBytes(16);
|
||||
const derived = await new Promise<Buffer>((resolve, reject) => {
|
||||
argon2(
|
||||
'argon2id',
|
||||
{
|
||||
message: Buffer.from(password, 'utf8'),
|
||||
nonce: salt,
|
||||
parallelism: PARALLELISM,
|
||||
tagLength: TAG_LENGTH,
|
||||
memory: MEMORY_KIB,
|
||||
passes: PASSES,
|
||||
},
|
||||
(error, result) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(result);
|
||||
}
|
||||
);
|
||||
});
|
||||
return {
|
||||
hash: `${PREFIX}${salt.toString('base64url')}$${derived.toString('base64url')}`,
|
||||
salt: '',
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { legacyUserId } from './identity.js';
|
||||
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| JsonValue[]
|
||||
| {
|
||||
[key: string]: JsonValue;
|
||||
};
|
||||
|
||||
export const parseJson = (value: unknown, context: string): JsonValue => {
|
||||
if (value === null || typeof value === 'boolean' || typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return value as JsonValue;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${context}: expected JSON text`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value) as JsonValue;
|
||||
} catch (error) {
|
||||
throw new Error(`${context}: invalid JSON`, { cause: error });
|
||||
}
|
||||
};
|
||||
|
||||
const asObject = (value: JsonValue): Record<string, JsonValue> =>
|
||||
value !== null && !Array.isArray(value) && typeof value === 'object' ? value : {};
|
||||
|
||||
const asStringArray = (value: JsonValue | undefined): string[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
||||
|
||||
const legacyAclRoleMap: Record<string, string> = {
|
||||
openClose: 'admin.profiles.manage',
|
||||
reset: 'admin.reset.schedule',
|
||||
update: 'admin.profiles.manage',
|
||||
fullUpdate: 'admin.profiles.manage',
|
||||
vote: 'admin.survey.open',
|
||||
globalNotice: 'admin.notice.manage',
|
||||
notice: 'admin.notice.manage',
|
||||
blockGeneral: 'admin.users.manage',
|
||||
};
|
||||
|
||||
export const mapLegacyRoles = (grade: number, rawAcl: JsonValue): string[] => {
|
||||
const roles = new Set<string>(['user']);
|
||||
if (grade >= 7) {
|
||||
roles.add('superuser');
|
||||
} else if (grade === 6) {
|
||||
roles.add('admin.profiles.manage');
|
||||
roles.add('admin.notice.manage');
|
||||
roles.add('admin.reset.schedule');
|
||||
roles.add('admin.resume.when-stopped');
|
||||
} else if (grade === 5) {
|
||||
roles.add('admin.users.manage');
|
||||
roles.add('admin.users.create');
|
||||
}
|
||||
|
||||
for (const [profile, permissions] of Object.entries(asObject(rawAcl))) {
|
||||
for (const permission of asStringArray(permissions)) {
|
||||
const mapped = legacyAclRoleMap[permission];
|
||||
if (mapped) {
|
||||
roles.add(`${mapped}:${profile}:default`);
|
||||
} else {
|
||||
roles.add(`legacy.acl.${permission}:${profile}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roles].sort();
|
||||
};
|
||||
|
||||
export const mapLegacySanctions = (grade: number, penalty: JsonValue): JsonValue => {
|
||||
const flags = grade === 0 ? ['legacy-blocked'] : [];
|
||||
return {
|
||||
...(flags.length ? { flags, suspendedUntil: '9999-12-31T23:59:59.999Z' } : {}),
|
||||
legacyPenalty: penalty,
|
||||
};
|
||||
};
|
||||
|
||||
export const classifyGameStorage = (
|
||||
namespace: string,
|
||||
key: string
|
||||
): 'persistent-projected' | 'persistent-archive' | 'season-state' => {
|
||||
if (/^inheritance_\d+$/.test(namespace)) {
|
||||
return 'persistent-projected';
|
||||
}
|
||||
if (/^user_\d+$/.test(namespace)) {
|
||||
return key === 'last_stat_reset' ? 'persistent-projected' : 'persistent-archive';
|
||||
}
|
||||
return 'season-state';
|
||||
};
|
||||
|
||||
export const parseStorageUserId = (namespace: string, prefix: 'inheritance' | 'user'): string | null => {
|
||||
const match = new RegExp(`^${prefix}_(\\d+)$`).exec(namespace);
|
||||
if (!match?.[1]) {
|
||||
return null;
|
||||
}
|
||||
return legacyUserId(Number.parseInt(match[1], 10));
|
||||
};
|
||||
|
||||
export const parseInheritanceValue = (value: JsonValue, context: string): number => {
|
||||
const scalar = Array.isArray(value) ? value[0] : value;
|
||||
if (typeof scalar === 'number' && Number.isFinite(scalar)) {
|
||||
return scalar;
|
||||
}
|
||||
if (typeof scalar === 'string') {
|
||||
const parsed = Number(scalar);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
throw new Error(`${context}: inheritance value must be numeric`);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { legacyUserId } from '../src/identity.js';
|
||||
import { classifyGameStorage, mapLegacyRoles, parseInheritanceValue, parseStorageUserId } from '../src/transform.js';
|
||||
|
||||
describe('legacy database transforms', () => {
|
||||
it('creates stable UUID-shaped user IDs without exposing the legacy sequence', () => {
|
||||
expect(legacyUserId(42)).toBe(legacyUserId(42));
|
||||
expect(legacyUserId(42)).not.toBe(legacyUserId(43));
|
||||
expect(legacyUserId(42)).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
it('maps legacy grades and scoped ACL without granting a scoped operator superuser', () => {
|
||||
expect(mapLegacyRoles(7, {})).toContain('superuser');
|
||||
expect(mapLegacyRoles(5, {})).toEqual(['admin.users.create', 'admin.users.manage', 'user']);
|
||||
expect(mapLegacyRoles(1, { che: ['reset', 'notice'] })).toEqual([
|
||||
'admin.notice.manage:che:default',
|
||||
'admin.reset.schedule:che:default',
|
||||
'user',
|
||||
]);
|
||||
});
|
||||
|
||||
it('separates persistent storage projections from current-season state', () => {
|
||||
expect(classifyGameStorage('inheritance_42', 'previous')).toBe('persistent-projected');
|
||||
expect(classifyGameStorage('user_42', 'last_stat_reset')).toBe('persistent-projected');
|
||||
expect(classifyGameStorage('game_env', 'year')).toBe('season-state');
|
||||
expect(parseStorageUserId('inheritance_42', 'inheritance')).toBe(legacyUserId(42));
|
||||
expect(parseInheritanceValue('123.5', 'fixture')).toBe(123.5);
|
||||
expect(parseInheritanceValue([123.5, null], 'fixture')).toBe(123.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user