From aa8bd4043a2bcdf476e7e83608c7d51e3f876389 Mon Sep 17 00:00:00 2001 From: zk Date: Mon, 18 May 2026 16:21:12 +0000 Subject: [PATCH] fix: claude issue --- .../server/src/scripts/migrate-to-postgres.ts | 65 +++-- .../scripts/reimport-priv-api-from-mongo.ts | 257 ++++++++++++++++++ 2 files changed, 300 insertions(+), 22 deletions(-) create mode 100644 typescript/server/src/scripts/reimport-priv-api-from-mongo.ts diff --git a/typescript/server/src/scripts/migrate-to-postgres.ts b/typescript/server/src/scripts/migrate-to-postgres.ts index 4294051f0..d56e794f8 100644 --- a/typescript/server/src/scripts/migrate-to-postgres.ts +++ b/typescript/server/src/scripts/migrate-to-postgres.ts @@ -244,18 +244,39 @@ function tsReq(ms: number): string { // ────────────────────────────────────────────────────────────────────────────── // API permission helpers -// (Old MongoDB data uses dash-separated permission names, not the underscore -// form in the current APIPermissions type - kept as plain string lookups.) +// Mongo validates against ALL_PERMISSIONS (underscore keys such as customise_profile). +// Some dumps may instead use dashed legacy spellings — accept either when migrating. // ────────────────────────────────────────────────────────────────────────────── +function permissionKeyDashVariant(canonicalUnderscoreKey: string): string { + return canonicalUnderscoreKey.replaceAll("_", "-"); +} + /** Extract one Postgres pm_* column from a MongoDB requestedPermissions array. */ -function perm(permissions: Array, key: string): boolean | null { - return permissions.includes(key) ? true : null; +function perm(permissions: Array, canonicalUnderscoreKey: string): boolean | null { + const dashed = permissionKeyDashVariant(canonicalUnderscoreKey); + + return permissions.includes(canonicalUnderscoreKey) || permissions.includes(dashed) + ? true + : null; } /** Extract one Postgres pm_* column from a MongoDB permissions record. */ -function permRec(permissions: Record, key: string): boolean | null { - return permissions[key] ?? null; +function permRec( + permissions: Record, + canonicalUnderscoreKey: string, +): boolean | null { + const dashed = permissionKeyDashVariant(canonicalUnderscoreKey); + + if (Object.hasOwn(permissions, canonicalUnderscoreKey)) { + return permissions[canonicalUnderscoreKey]!; + } + + if (Object.hasOwn(permissions, dashed)) { + return permissions[dashed]!; + } + + return null; } // ────────────────────────────────────────────────────────────────────────────── @@ -650,14 +671,14 @@ async function main(): Promise { client_secret: c.clientSecret, name: c.name, author: c.author, - pm_customise_profile: perm(p, "customise-profile"), - pm_customise_score: perm(p, "customise-score"), - pm_customise_session: perm(p, "customise-session"), - pm_delete_score: perm(p, "delete-score"), - pm_manage_rivals: perm(p, "manage-rivals"), - pm_manage_targets: perm(p, "manage-targets"), - pm_submit_score: perm(p, "submit-score"), - pm_manage_challenges: perm(p, "manage-challenges"), + pm_customise_profile: perm(p, "customise_profile"), + pm_customise_score: perm(p, "customise_score"), + pm_customise_session: perm(p, "customise_session"), + pm_delete_score: perm(p, "delete_score"), + pm_manage_rivals: perm(p, "manage_rivals"), + pm_manage_targets: perm(p, "manage_targets"), + pm_submit_score: perm(p, "submit_score"), + pm_manage_challenges: perm(p, "manage_challenges"), api_key_template: c.apiKeyTemplate, api_key_filename: c.apiKeyFilename, }; @@ -1352,14 +1373,14 @@ async function main(): Promise { user_id: t.userID!, identifier: t.identifier, - pm_customise_profile: permRec(p, "customise-profile"), - pm_customise_score: permRec(p, "customise-score"), - pm_customise_session: permRec(p, "customise-session"), - pm_delete_score: permRec(p, "delete-score"), - pm_manage_rivals: permRec(p, "manage-rivals"), - pm_manage_targets: permRec(p, "manage-targets"), - pm_submit_score: permRec(p, "submit-score"), - pm_manage_challenges: permRec(p, "manage-challenges"), + pm_customise_profile: permRec(p, "customise_profile"), + pm_customise_score: permRec(p, "customise_score"), + pm_customise_session: permRec(p, "customise_session"), + pm_delete_score: permRec(p, "delete_score"), + pm_manage_rivals: permRec(p, "manage_rivals"), + pm_manage_targets: permRec(p, "manage_targets"), + pm_submit_score: permRec(p, "submit_score"), + pm_manage_challenges: permRec(p, "manage_challenges"), from_oauth2_client: t.fromAPIClient, }; }); diff --git a/typescript/server/src/scripts/reimport-priv-api-from-mongo.ts b/typescript/server/src/scripts/reimport-priv-api-from-mongo.ts new file mode 100644 index 000000000..3d87da41a --- /dev/null +++ b/typescript/server/src/scripts/reimport-priv-api-from-mongo.ts @@ -0,0 +1,257 @@ +/** + * Re-import Mongo `api-clients` → Postgres `priv_api_client` and Mongo `api-tokens` → `priv_api_token`. + * + * Use after correcting permission-field mapping bugs — upserts Mongo rows into Postgres (overwrite on PK conflict). + * + * Postgres dependency order: + * If you truncate tokens only: `TRUNCATE priv_api_token;` + * If you redo clients too (recommended when fixing client permission columns): + * `TRUNCATE priv_api_token, priv_api_client;` + * (ordering does not matter in one statement; tokens must be cleared if you truncate clients.) + * + * Heads-up: `priv_discord_user_map` FKs into `priv_api_token` — resetting tokens may unlink Discord API mapping rows until you reconcile. + * + * Env: + * MONGO_URL — MongoDB URL (default: mongodb://mongo/tachi) + * POSTGRES_URL — required + * + * From `typescript/server`: + * MONGO_URL=... POSTGRES_URL=... bun run src/scripts/reimport-priv-api-from-mongo.ts + */ + +import type { Database, NewPrivApiClient, NewPrivApiToken } from "tachi-db"; + +import { + type Insertable, + Kysely, + PostgresDialect, + type RawBuilder, + sql, +} from "kysely"; +import monk from "monk"; +import { Pool } from "pg"; + +import type { + MongoApiClientsCollectionDocument, + MongoApiTokensCollectionDocument, +} from "./migrate-to-postgres.mongo-docs"; + +const MONGO_URL = process.env.MONGO_URL ?? "mongodb://mongo/tachi"; +const POSTGRES_URL = process.env.POSTGRES_URL; + +if (!POSTGRES_URL) { + console.error("[reimport-priv-api] POSTGRES_URL is not set."); + process.exit(1); +} + +const mongoDB = monk(MONGO_URL); + +const pg = new Kysely({ + dialect: new PostgresDialect({ + pool: new Pool({ connectionString: POSTGRES_URL }), + }), +}); + +// ── Same permission normalization as migrate-to-postgres.ts ───────────────── + +function permissionKeyDashVariant(canonicalUnderscoreKey: string): string { + return canonicalUnderscoreKey.replaceAll("_", "-"); +} + +/** requestedPermissions entries may use underscore (`customise_profile`) or dashed legacy strings. */ +function perm(permissions: Array, canonicalUnderscoreKey: string): boolean | null { + const dashed = permissionKeyDashVariant(canonicalUnderscoreKey); + + return permissions.includes(canonicalUnderscoreKey) || permissions.includes(dashed) + ? true + : null; +} + +function permRec( + permissions: Record, + canonicalUnderscoreKey: string, +): boolean | null { + const dashed = permissionKeyDashVariant(canonicalUnderscoreKey); + + if (Object.hasOwn(permissions, canonicalUnderscoreKey)) { + return permissions[canonicalUnderscoreKey]!; + } + + if (Object.hasOwn(permissions, dashed)) { + return permissions[dashed]!; + } + + return null; +} + +const INSERT_CHUNK = 500; + +/** ON CONFLICT DO UPDATE — set each column from the proposed row (`excluded`). */ +function onConflictExcludedAll(exampleRow: Record): Record> { + const toSet: Record> = {}; + + for (const key of Object.keys(exampleRow)) { + toSet[key] = sql`excluded.${sql.ref(key)}`; + } + + return toSet; +} + +async function batchUpsertPrivApiClient(rows: ReadonlyArray>): Promise { + if (rows.length === 0) { + return; + } + + for (let i = 0; i < rows.length; i = i + INSERT_CHUNK) { + const chunk = rows.slice(i, i + INSERT_CHUNK); + // Sequential chunks — avoid oversized multi-row INSERT payloads. + // eslint-disable-next-line no-await-in-loop -- bounded batch upserts + await pg + .insertInto("priv_api_client") + .values(chunk as never) + .onConflict((oc) => + oc.column("client_id").doUpdateSet(onConflictExcludedAll(chunk[0] as Record)), + ) + .execute(); + } +} + +async function batchUpsertPrivApiToken(rows: ReadonlyArray>): Promise { + if (rows.length === 0) { + return; + } + + for (let i = 0; i < rows.length; i = i + INSERT_CHUNK) { + const chunk = rows.slice(i, i + INSERT_CHUNK); + // eslint-disable-next-line no-await-in-loop -- bounded batch upserts + await pg + .insertInto("priv_api_token") + .values(chunk as never) + .onConflict((oc) => + oc.column("token").doUpdateSet(onConflictExcludedAll(chunk[0] as Record)), + ) + .execute(); + } +} + +function getFromApiClient(doc: MongoApiTokensCollectionDocument): string | null { + const d = doc as { + fromOAuth2Client?: string | null; + } & MongoApiTokensCollectionDocument; + if (typeof d.fromOAuth2Client === "string") { + return d.fromOAuth2Client; + } + return d.fromAPIClient; +} + +function mapApiClientRow(c: MongoApiClientsCollectionDocument): NewPrivApiClient { + const extended = c as { + redirectURI?: string | null; + redirectUri?: string | null; + webhookURI?: string | null; + webhookUri?: string | null; + } & MongoApiClientsCollectionDocument; + + const p = c.requestedPermissions as unknown as Array; + + return { + client_id: c.clientID, + client_secret: c.clientSecret, + name: c.name, + author: c.author, + pm_customise_profile: perm(p, "customise_profile"), + pm_customise_score: perm(p, "customise_score"), + pm_customise_session: perm(p, "customise_session"), + pm_delete_score: perm(p, "delete_score"), + pm_manage_rivals: perm(p, "manage_rivals"), + pm_manage_targets: perm(p, "manage_targets"), + pm_submit_score: perm(p, "submit_score"), + pm_manage_challenges: perm(p, "manage_challenges"), + api_key_template: c.apiKeyTemplate, + api_key_filename: c.apiKeyFilename, + redirect_uri: extended.redirectUri ?? extended.redirectURI ?? null, + webhook_uri: extended.webhookUri ?? extended.webhookURI ?? null, + }; +} + +async function main(): Promise { + console.log("[reimport-priv-api] fetching api-clients from Mongo..."); + const clients = await mongoDB + .get("api-clients") + .find({}); + + const clientRows: Array = clients.map((c) => mapApiClientRow(c)); + + console.log( + `[reimport-priv-api] upserting ${clientRows.length} row(s) into priv_api_client (conflict target: client_id)...`, + ); + + await batchUpsertPrivApiClient(clientRows); + + console.log("[reimport-priv-api] fetching api-tokens from Mongo..."); + const apiTokens = await mongoDB + .get("api-tokens") + .find({}); + + const existingClientIds = new Set( + (await pg.selectFrom("priv_api_client").select("client_id").execute()).map( + (r) => r.client_id, + ), + ); + + const validTokens = apiTokens.filter((t) => { + const fromClient = getFromApiClient(t); + return ( + t.token !== null && + t.userID !== null && + (fromClient === null || fromClient === undefined || existingClientIds.has(fromClient)) + ); + }); + + if (validTokens.length !== apiTokens.length) { + console.warn( + `[reimport-priv-api] Skipping ${apiTokens.length - validTokens.length} token row(s): null token/userID or dangling oauth client`, + ); + } + + const tokenRows: Array = validTokens.map((t) => { + const fromClient = getFromApiClient(t); + + const p = + t.permissions && typeof t.permissions === "object" + ? t.permissions + : ({} as Record); + + return { + token: t.token!, + user_id: t.userID!, + identifier: t.identifier, + pm_customise_profile: permRec(p, "customise_profile"), + pm_customise_score: permRec(p, "customise_score"), + pm_customise_session: permRec(p, "customise_session"), + pm_delete_score: permRec(p, "delete_score"), + pm_manage_rivals: permRec(p, "manage_rivals"), + pm_manage_targets: permRec(p, "manage_targets"), + pm_submit_score: permRec(p, "submit_score"), + pm_manage_challenges: permRec(p, "manage_challenges"), + from_oauth2_client: fromClient ?? null, + }; + }); + + console.log( + `[reimport-priv-api] upserting ${tokenRows.length} row(s) into priv_api_token (conflict target: token)...`, + ); + + await batchUpsertPrivApiToken(tokenRows); + + console.log("[reimport-priv-api] done."); + + await mongoDB.close(); + await pg.destroy(); +} + +main().catch((err) => { + console.error("[reimport-priv-api]", err); + + process.exit(1); +});