diff --git a/.cursor/skills/exhaustive-switch/SKILL.md b/.cursor/skills/exhaustive-switch/SKILL.md new file mode 100644 index 000000000..3d8bb74cd --- /dev/null +++ b/.cursor/skills/exhaustive-switch/SKILL.md @@ -0,0 +1,60 @@ +--- +name: exhaustive-switch +description: Prefer staticAssertUnreachable in the default branch of switches that must cover every variant of a union, so exhaustiveness is checked at compile time. Use when writing or reviewing switch on discriminated unions, string literal unions, enums, or any switch meant to be exhaustive in the Tachi TypeScript server. +--- + +# Exhaustive switches with `staticAssertUnreachable` + +## When to use + +Use this pattern when the `switch` is **meant to be exhaustive**: every value of the discriminant type should have a `case`, and adding a new variant later should cause a **TypeScript error** until a new `case` is added. + +## Pattern + +1. Import `staticAssertUnreachable` from `#utils/misc.js` (server code). + +2. Bind the switched value in a variable if needed so the `default` branch receives a narrowed type. + +3. In `default`, call `staticAssertUnreachable(discriminant)` with the same value you are switching on (after all `case`s, TypeScript should infer its type as `never`). + +```typescript +import { staticAssertUnreachable } from "#utils/misc.js"; + +switch (value) { + case "a": + return 1; + case "b": + return 2; + default: + staticAssertUnreachable(value); +} +``` + +For a block body: + +```typescript +default: { + staticAssertUnreachable(gpt); +} +``` + +Reference: `staticAssertUnreachable` in `typescript/server/src/utils/misc.ts` — it takes `never` and throws at runtime if execution reaches it. + +## Why + +- **Compile time**: If a new union member is added and not handled, `default` no longer receives `never`, and TypeScript reports an error. +- **Runtime**: If a value slips through anyway, you get a clear error instead of silent wrong behavior. + +## Avoid + +- A `default` that only `throw new Error("unreachable")` **without** passing a `never`-typed value — you lose exhaustiveness checking. +- `default: break` or empty `default` on switches that are supposed to be exhaustive — same problem. + +## When not to use + +- Switches that are **intentionally partial** (e.g. only handle some cases and fall through to shared logic). Use an explicit `default` that handles “everything else” with correct typing, not `staticAssertUnreachable`. +- If the discriminant is typed too widely (e.g. `string`), `default` will not be `never` and the pattern will not typecheck — narrow the type first or handle the real domain of values. + +## Imports + +In `typescript/server`, prefer `#utils/misc.js`. Relative imports to `./misc` are acceptable next to `utils/misc.ts` (see `conversion.ts`). diff --git a/Justfile-migrate b/Justfile-migrate index 9e4d4b59b..06b11d069 100644 --- a/Justfile-migrate +++ b/Justfile-migrate @@ -11,7 +11,7 @@ migrate-v3: mongo-to-pg POSTGRES_DB_NAME MONGO_DB_NAME: just db-reset "{{ POSTGRES_DB_NAME }}" - cd typescript/server && POSTGRES_URL="{{POSTGRES_URL}}/{{POSTGRES_DB_NAME}}" MONGO_URL="mongodb://tachi-mongo/{{MONGO_DB_NAME}}" \ + cd typescript/server && TACHI_CONFIG=$(cat conf.json5) VERSION=$(jq -r .version package.json) COMMIT_HASH=$(git rev-parse --short HEAD) POSTGRES_URL="{{POSTGRES_URL}}/{{POSTGRES_DB_NAME}}" MONGO_URL="mongodb://tachi-mongo/{{MONGO_DB_NAME}}" \ bun run src/scripts/migrate-to-postgres.ts # Rollback the seeds to the state stored somewhere. diff --git a/db/migrations/20260301154256_genesis.sql b/db/migrations/20260301154256_genesis.sql index 93c951f95..fa5e90658 100644 --- a/db/migrations/20260301154256_genesis.sql +++ b/db/migrations/20260301154256_genesis.sql @@ -707,6 +707,8 @@ CREATE TABLE "score" ( derived_data JSONB NOT NULL, -- f(chart, score.data) => score.calculated_data calculated_data JSONB NOT NULL, + -- What was "judgements" in Mongo + judgements JSONB NOT NULL, meta JSONB NOT NULL, diff --git a/typescript/client/src/components/tables/dropdowns/components/ChunithmScoreDropdownParts.tsx b/typescript/client/src/components/tables/dropdowns/components/ChunithmScoreDropdownParts.tsx index 39174f98d..3a85b0c2a 100644 --- a/typescript/client/src/components/tables/dropdowns/components/ChunithmScoreDropdownParts.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/ChunithmScoreDropdownParts.tsx @@ -8,7 +8,7 @@ import { type ChartDocument, type Difficulties, type PBScoreDocument, - type ScoreData, + type MongoScoreData, type ScoreDocument, type SongDocument, } from "tachi-common"; @@ -106,7 +106,7 @@ function GraphComponent({ type, }: { difficulty: Difficulties["chunithm:Single"]; - scoreData: ScoreData<"chunithm:Single">; + scoreData: MongoScoreData<"chunithm:Single">; song: SongDocument<"chunithm">; type: ChartType; }) { diff --git a/typescript/client/src/components/tables/dropdowns/components/MaimaiDXScoreDropdownParts.tsx b/typescript/client/src/components/tables/dropdowns/components/MaimaiDXScoreDropdownParts.tsx index 4f21770f9..a8bc7a05f 100644 --- a/typescript/client/src/components/tables/dropdowns/components/MaimaiDXScoreDropdownParts.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/MaimaiDXScoreDropdownParts.tsx @@ -8,7 +8,7 @@ import { type ChartDocument, type Difficulties, type PBScoreDocument, - type ScoreData, + type MongoScoreData, type ScoreDocument, type SongDocument, } from "tachi-common"; @@ -103,7 +103,7 @@ function GraphComponent({ type, }: { difficulty: Difficulties["maimaidx:Single"]; - scoreData: ScoreData<"maimaidx:Single">; + scoreData: MongoScoreData<"maimaidx:Single">; song: SongDocument<"maimaidx">; type: ChartType; }) { diff --git a/typescript/client/src/components/tables/dropdowns/components/OngekiScoreDropdownParts.tsx b/typescript/client/src/components/tables/dropdowns/components/OngekiScoreDropdownParts.tsx index 0b6db7195..0b42d2efa 100644 --- a/typescript/client/src/components/tables/dropdowns/components/OngekiScoreDropdownParts.tsx +++ b/typescript/client/src/components/tables/dropdowns/components/OngekiScoreDropdownParts.tsx @@ -7,7 +7,7 @@ import { Nav } from "react-bootstrap"; import { type ChartDocument, type PBScoreDocument, - type ScoreData, + type MongoScoreData, type ScoreDocument, type SongDocument, } from "tachi-common"; @@ -128,7 +128,7 @@ function GraphComponent({ chart, }: { chart: ChartDocument<"ongeki:Single">; - scoreData: ScoreData<"ongeki:Single">; + scoreData: MongoScoreData<"ongeki:Single">; song: SongDocument<"ongeki">; type: ChartType; }) { diff --git a/typescript/common/src/types/documents.ts b/typescript/common/src/types/documents.ts index 8c3689c11..2a3f65d2f 100644 --- a/typescript/common/src/types/documents.ts +++ b/typescript/common/src/types/documents.ts @@ -27,6 +27,8 @@ import type { SessionRatingAlgorithms, SongDocumentData, UserAuthLevels, + V3Game, + V3GameToGPTString, Versions, } from "../types"; import type { APIPermissions } from "./api"; @@ -442,7 +444,10 @@ export type QuestSubscriptionDocument = { } ); -export type ScoreData = { +export type PgScoreData = ProvidedMetrics[V3GameToGPTString[Game]] & + DerivedMetrics[V3GameToGPTString[Game]]; + +export type MongoScoreData = { enumIndexes: ScoreEnumIndexes; judgements: Partial>; optional: { @@ -456,7 +461,7 @@ export interface ScoreDocument { game: GPTStringToGame[GPT]; playtype: GPTStringToPlaytype[GPT]; userID: integer; - scoreData: ScoreData; + scoreData: MongoScoreData; scoreMeta: Partial; calculatedData: Partial>; timeAchieved: integer | null; @@ -496,7 +501,7 @@ export interface PBScoreDocument { highlight: boolean; isPrimary: boolean; timeAchieved: number | null; - scoreData: ScoreData; + scoreData: MongoScoreData; calculatedData: Partial>; } diff --git a/typescript/common/src/types/game-config.ts b/typescript/common/src/types/game-config.ts index 20c33aac7..8110ddfba 100644 --- a/typescript/common/src/types/game-config.ts +++ b/typescript/common/src/types/game-config.ts @@ -99,6 +99,58 @@ export type V3Game = | "usc-keyboard" | "wacca"; +export type V3GameToGPTString = { + "iidx-sp": "iidx:SP"; + "iidx-dp": "iidx:DP"; + museca: "museca:Single"; + sdvx: "sdvx:Single"; + "bms-14k": "bms:14K"; + "bms-7k": "bms:7K"; + "gitadora-dora": "gitadora:Dora"; + "gitadora-gita": "gitadora:Gita"; + chunithm: "chunithm:Single"; + wacca: "wacca:Single"; + jubeat: "jubeat:Single"; + popn: "popn:9B"; + maimai: "maimai:Single"; + maimaidx: "maimaidx:Single"; + "pms-controller": "pms:Controller"; + "pms-keyboard": "pms:Keyboard"; + "usc-controller": "usc:Controller"; + "usc-keyboard": "usc:Keyboard"; + "itg-stamina": "itg:Stamina"; + arcaea: "arcaea:Touch"; + ongeki: "ongeki:Single"; + "ddr-sp": "ddr:SP"; + "ddr-dp": "ddr:DP"; +}; + +export type GPTStringToV3Game = { + "iidx:SP": "iidx-sp"; + "iidx:DP": "iidx-dp"; + "museca:Single": "museca"; + "sdvx:Single": "sdvx"; + "bms:14K": "bms-14k"; + "bms:7K": "bms-7k"; + "gitadora:Dora": "gitadora-dora"; + "gitadora:Gita": "gitadora-gita"; + "chunithm:Single": "chunithm"; + "wacca:Single": "wacca"; + "jubeat:Single": "jubeat"; + "popn:9B": "popn"; + "maimai:Single": "maimai"; + "maimaidx:Single": "maimaidx"; + "pms:Controller": "pms-controller"; + "pms:Keyboard": "pms-keyboard"; + "usc:Controller": "usc-controller"; + "usc:Keyboard": "usc-keyboard"; + "itg:Stamina": "itg-stamina"; + "arcaea:Touch": "arcaea"; + "ongeki:Single": "ongeki"; + "ddr:SP": "ddr-sp"; + "ddr:DP": "ddr-dp"; +}; + export type GPTStrings = { [G in GameGroup]: `${G}:${Playtypes[G]}`; }; diff --git a/typescript/db/src/generated/public/Score.ts b/typescript/db/src/generated/public/Score.ts index b1b315915..c46f2940d 100644 --- a/typescript/db/src/generated/public/Score.ts +++ b/typescript/db/src/generated/public/Score.ts @@ -29,6 +29,8 @@ export default interface ScoreTable { calculated_data: ColumnType; + judgements: ColumnType; + meta: ColumnType; time_achieved: ColumnType; diff --git a/typescript/server/src/actions/create-oauth2-auth-code.ts b/typescript/server/src/actions/create-oauth2-auth-code.ts index f9c76628a..5e8c25421 100644 --- a/typescript/server/src/actions/create-oauth2-auth-code.ts +++ b/typescript/server/src/actions/create-oauth2-auth-code.ts @@ -2,24 +2,21 @@ import { MakeAction } from "#lib/actions/actions.js"; import DB from "#services/pg/db.js"; import { Random20Hex } from "#utils/misc.js"; -export const ACTION_CreateOAuth2AuthCode = MakeAction( - "CREATE_OAUTH2_AUTH_CODE", - async (taker) => { - const code = Random20Hex(); - const createdOn = Date.now(); +export const ACTION_CreateOAuth2AuthCode = MakeAction("CREATE_OAUTH2_AUTH_CODE", async (taker) => { + const code = Random20Hex(); + const createdOn = Date.now(); - await DB.insertInto("priv_oauth2_auth_token") - .values({ - token: code, - user_id: taker.acct.id, - created_on: new Date(createdOn).toISOString(), - }) - .execute(); + await DB.insertInto("priv_oauth2_auth_token") + .values({ + token: code, + user_id: taker.acct.id, + created_on: new Date(createdOn).toISOString(), + }) + .execute(); - return { - code, - userID: taker.acct.id, - createdOn, - }; - }, -); + return { + code, + userID: taker.acct.id, + createdOn, + }; +}); diff --git a/typescript/server/src/actions/customise-score.test.ts b/typescript/server/src/actions/customise-score.test.ts new file mode 100644 index 000000000..c4fa4a596 --- /dev/null +++ b/typescript/server/src/actions/customise-score.test.ts @@ -0,0 +1,174 @@ +import { mergeScoreDataFromPg, mongoScoreDataToPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { type MongoScoreData, type ScoreDocument } from "tachi-common"; +import { describe, expect, it } from "vitest"; + +import { ACTION_CustomiseScore } from "./customise-score"; + +// ─── mergeScoreDataFromPg roundtrip ─────────────────────────────────────────── + +describe("mergeScoreDataFromPg", () => { + it("inverts mongoScoreDataToPg for iidx:SP", () => { + const original = { + grade: "F", + lamp: "FAILED", + percent: 50, + score: 200, + optional: {}, + } as MongoScoreData<"iidx:SP">; + + const { data, derived } = mongoScoreDataToPg("iidx:SP", original); + const back = mergeScoreDataFromPg("iidx:SP", data, derived); + + expect(back).toMatchObject({ + grade: "F", + lamp: "FAILED", + percent: 50, + score: 200, + }); + }); +}); + +// ─── ACTION_CustomiseScore ──────────────────────────────────────────────────── + +describe("ACTION_CustomiseScore", () => { + let counter = 0; + + async function seedIidxScore(userId: number) { + const n = ++counter; + const songId = `song-cs-${n}`; + const chartId = `chart-cs-${n}`; + const scoreId = `score-cs-${n}`; + const now = new Date().toISOString(); + + await DB.insertInto("song") + .values({ + id: songId, + legacy_id: n, + game_group: "iidx", + title: "S", + artist: "A", + data: JSON.stringify({}), + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chartId, + legacy_id: chartId, + game: "iidx-sp", + song_id: songId, + level: "10", + level_num: 10, + is_primary: true, + difficulty: "NORMAL", + data: JSON.stringify({}), + }) + .execute(); + + const { data, derived } = mongoScoreDataToPg("iidx:SP", { + grade: "F", + lamp: "FAILED", + percent: 0, + score: 100, + optional: {}, + } as MongoScoreData<"iidx:SP">); + + await DB.insertInto("score") + .values({ + id: scoreId, + user_id: userId, + chart_id: chartId, + game: "iidx-sp", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: null, + time_added: now, + highlight: false, + comment: null, + }) + .execute(); + + return { scoreId, chartId }; + } + + it("updates comment and returns the score document", async () => { + const { id: userId, username } = await seedUser(); + const { scoreId } = await seedIidxScore(userId); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_CustomiseScore(taker, { + scoreID: scoreId, + comment: "nice", + }); + + expect((result.score as ScoreDocument).comment).toBe("nice"); + + const row = await DB.selectFrom("score") + .select("comment") + .where("id", "=", scoreId) + .executeTakeFirst(); + + expect(row?.comment).toBe("nice"); + }); + + it("updates highlight and propagates to pb when present", async () => { + const { id: userId, username } = await seedUser(); + const { scoreId, chartId } = await seedIidxScore(userId); + + await DB.insertInto("pb") + .values({ + user_id: userId, + chart_id: chartId, + lens: null, + data: JSON.stringify({}), + derived_data: JSON.stringify({}), + calculated_data: JSON.stringify({}), + ranking_value: 0, + ranking_value_tb1: null, + ranking_value_tb2: null, + ranking_value_tb3: null, + ranking_value_tb4: null, + ranking_value_tb5: null, + highlight: false, + time_achieved: null, + }) + .execute(); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_CustomiseScore(taker, { + scoreID: scoreId, + highlight: true, + }); + + const [scoreRow, pbRow] = await Promise.all([ + DB.selectFrom("score").select("highlight").where("id", "=", scoreId).executeTakeFirst(), + DB.selectFrom("pb") + .select("highlight") + .where("user_id", "=", userId) + .where("chart_id", "=", chartId) + .executeTakeFirst(), + ]); + + expect(scoreRow?.highlight).toBe(true); + expect(pbRow?.highlight).toBe(true); + }); + + it("throws 400 when nothing to change", async () => { + const { id: userId, username } = await seedUser(); + const { scoreId } = await seedIidxScore(userId); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect(ACTION_CustomiseScore(taker, { scoreID: scoreId })).rejects.toMatchObject({ + code: 400, + }); + }); +}); diff --git a/typescript/server/src/actions/customise-score.ts b/typescript/server/src/actions/customise-score.ts new file mode 100644 index 000000000..b82d0c796 --- /dev/null +++ b/typescript/server/src/actions/customise-score.ts @@ -0,0 +1,51 @@ +import { MakeAction } from "#lib/actions/actions.js"; +import { LoadScoreDocumentById } from "#lib/db-formats/score.js"; +import DB from "#services/pg/db.js"; +import { ExpectedErr } from "bliss"; + +export const ACTION_CustomiseScore = MakeAction("CUSTOMISE_SCORE", async (_taker, input) => { + if (input.comment === undefined && input.highlight === undefined) { + throw new ExpectedErr(400, "This request modifies nothing about the score."); + } + + await DB.transaction().execute(async (trx) => { + const scoreRow = await trx + .selectFrom("score") + .select(["chart_id", "user_id"]) + .where("id", "=", input.scoreID) + .executeTakeFirst(); + + if (!scoreRow) { + throw new ExpectedErr(404, "This score does not exist."); + } + + const setScore: { comment?: string | null; highlight?: boolean } = {}; + + if (input.comment !== undefined) { + setScore.comment = input.comment; + } + + if (input.highlight !== undefined) { + setScore.highlight = input.highlight; + } + + await trx.updateTable("score").set(setScore).where("id", "=", input.scoreID).execute(); + + if (input.highlight === true || input.highlight === false) { + await trx + .updateTable("pb") + .set({ highlight: input.highlight }) + .where("user_id", "=", scoreRow.user_id) + .where("chart_id", "=", scoreRow.chart_id) + .execute(); + } + }); + + const doc = await LoadScoreDocumentById(input.scoreID); + + if (!doc) { + throw new ExpectedErr(500, "Score disappeared after update."); + } + + return { score: doc }; +}); diff --git a/typescript/server/src/actions/follow-user.test.ts b/typescript/server/src/actions/follow-user.test.ts index 46d1074b1..a57751551 100644 --- a/typescript/server/src/actions/follow-user.test.ts +++ b/typescript/server/src/actions/follow-user.test.ts @@ -63,7 +63,6 @@ describe("ACTION_FollowUser", () => { const originalMax = ServerConfig.MAX_FOLLOWING_AMOUNT; try { - // @ts-expect-error deliberate mutation for test isolation ServerConfig.MAX_FOLLOWING_AMOUNT = 1; // Seed one real follow so the count equals the new cap. @@ -78,7 +77,6 @@ describe("ACTION_FollowUser", () => { code: 400, }); } finally { - // @ts-expect-error deliberate mutation for test isolation ServerConfig.MAX_FOLLOWING_AMOUNT = originalMax; } }); diff --git a/typescript/server/src/game-implementations/types.ts b/typescript/server/src/game-implementations/types.ts index 45d930e25..e135df042 100644 --- a/typescript/server/src/game-implementations/types.ts +++ b/typescript/server/src/game-implementations/types.ts @@ -13,7 +13,7 @@ import type { PBReference, PBScoreDocument, ProfileRatingAlgorithms, - ScoreData, + MongoScoreData, ScoreDocument, ScoreRatingAlgorithms, SessionRatingAlgorithms, @@ -130,14 +130,14 @@ export type GPTDerivers = { // New-style deriver; just f(scoreData, chart) -> derivedMetrics // instead of the overly complex shit above. export type GPTNewDeriver = ( - scoreData: ScoreData, + scoreData: MongoScoreData, chart: ChartDocument, ) => DerivedMetrics[GPT]; // New-style score calc; just f(scoreData, derivedData, chart) -> calculatedData // instead of the per-algorithm record above. export type GPTNewCalcs = ( - scoreData: ScoreData, + scoreData: MongoScoreData, derivedData: DerivedMetrics[GPT], chart: ChartDocument, ) => Record; diff --git a/typescript/server/src/lib/actions/actions.ts b/typescript/server/src/lib/actions/actions.ts index 737693c30..42aeed679 100644 --- a/typescript/server/src/lib/actions/actions.ts +++ b/typescript/server/src/lib/actions/actions.ts @@ -258,6 +258,16 @@ export const ActionSignatures = { consumedBy: z.null(), }), }, + CUSTOMISE_SCORE: { + input: z.object({ + scoreID: z.string(), + comment: z.union([z.string().min(1).max(120), z.null()]).optional(), + highlight: z.boolean().optional(), + }), + output: z.object({ + score: z.unknown(), + }), + }, UPSERT_KAI_AUTH_TOKEN: { input: z.object({ service: z.enum(["EAG", "FLO", "MIN"]), diff --git a/typescript/server/src/lib/db-formats/chart.ts b/typescript/server/src/lib/db-formats/chart.ts index f0c45ba02..db6f404e8 100644 --- a/typescript/server/src/lib/db-formats/chart.ts +++ b/typescript/server/src/lib/db-formats/chart.ts @@ -1,5 +1,11 @@ import DB from "#services/pg/db"; -import type { ChartDocument, ChartDocumentData, Difficulties, GPTString, Versions } from "tachi-common"; +import type { + ChartDocument, + ChartDocumentData, + Difficulties, + GPTString, + Versions, +} from "tachi-common"; import { V3ToGamePT } from "tachi-common"; import type { Game } from "tachi-db"; @@ -14,7 +20,16 @@ export async function GetChartsBySongPgId( songLegacyId: number, ): Promise { const rows = await DB.selectFrom("chart") - .select(["id", "legacy_id", "game", "level", "level_num", "is_primary", "difficulty", "data"]) + .select([ + "id", + "legacy_id", + "game", + "level", + "level_num", + "is_primary", + "difficulty", + "data", + ]) .where("song_id", "=", songPgId) .where("game", "=", gamePt) .execute(); diff --git a/typescript/server/src/lib/db-formats/import-document.test.ts b/typescript/server/src/lib/db-formats/import-document.test.ts new file mode 100644 index 000000000..7f178b99c --- /dev/null +++ b/typescript/server/src/lib/db-formats/import-document.test.ts @@ -0,0 +1,54 @@ +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { describe, expect, it } from "vitest"; + +import { LoadImportDocumentById } from "./import-document"; + +describe("LoadImportDocumentById", () => { + it("returns undefined when import does not exist", async () => { + const doc = await LoadImportDocumentById("nonexistent-import-id"); + + expect(doc).toBeUndefined(); + }); + + it("composes an ImportDocument from Postgres rows", async () => { + const { id: userId } = await seedUser(); + const importId = `import-fmt-${Date.now()}`; + const now = new Date().toISOString(); + + await DB.insertInto("import") + .values({ + id: importId, + user_id: userId, + time_started: now, + time_finished: now, + game_group: "iidx", + import_type: "ir/direct-manual", + user_intent: true, + service: "test-svc", + }) + .execute(); + + await DB.insertInto("import_game").values({ id: importId, game: "iidx-sp" }).execute(); + + await DB.insertInto("import_error") + .values({ import_id: importId, type: "T", message: "m" }) + .execute(); + + const doc = await LoadImportDocumentById(importId); + + expect(doc).toMatchObject({ + importID: importId, + userID: userId, + game: "iidx", + importType: "ir/direct-manual", + userIntent: true, + errors: [{ type: "T", message: "m" }], + gptStrings: expect.arrayContaining(["iidx:SP"]), + goalInfo: [], + questInfo: [], + }); + + expect(doc?.playtypes).toContain("SP"); + }); +}); diff --git a/typescript/server/src/lib/db-formats/import-document.ts b/typescript/server/src/lib/db-formats/import-document.ts new file mode 100644 index 000000000..cd0f952d7 --- /dev/null +++ b/typescript/server/src/lib/db-formats/import-document.ts @@ -0,0 +1,105 @@ +import DB from "#services/pg/db"; +import { ISO8601ToUnixMilliseconds } from "#utils/time"; +import { + type ClassDelta, + GetGPTString, + type GPTString, + type ImportDocument, + type ImportTypes, + V3ToGamePT, +} from "tachi-common"; + +/** + * Build a full {@link ImportDocument} from normalized Postgres import tables. + * `goalInfo` / `questInfo` are always empty (historical import_goal / import_quest were not migrated). + */ +export async function LoadImportDocumentById( + importID: string, +): Promise { + const base = await DB.selectFrom("import") + .selectAll() + .where("id", "=", importID) + .executeTakeFirst(); + + if (!base) { + return undefined; + } + + const [games, errors, classes, sessions, scoreIds] = await Promise.all([ + DB.selectFrom("import_game").select("game").where("id", "=", importID).execute(), + DB.selectFrom("import_error") + .select(["type", "message"]) + .where("import_id", "=", importID) + .execute(), + DB.selectFrom("import_class") + .select(["game", "set", "prev", "new"]) + .where("import_id", "=", importID) + .execute(), + DB.selectFrom("import_session") + .select(["session_id", "type"]) + .where("import_id", "=", importID) + .execute(), + DB.selectFrom("score").select("id").where("import_id", "=", importID).execute(), + ]); + + const gptStringSet = new Set(); + + for (const g of games) { + const { game, playtype } = V3ToGamePT(g.game); + + gptStringSet.add(GetGPTString(game, playtype)); + } + + const gptStrings = [...gptStringSet]; + + const playtypeSet = new Set( + games.map((g) => { + const { playtype } = V3ToGamePT(g.game); + + return playtype; + }), + ); + + const classDeltas: Array = classes.map((c) => { + const { game, playtype } = V3ToGamePT(c.game); + + return { + game, + playtype, + set: c.set as ClassDelta["set"], + old: c.prev, + new: c.new, + }; + }); + + const createdSessions = sessions.map((s) => { + const t = s.type; + + const cap = t.length > 0 ? t.charAt(0).toUpperCase() + t.slice(1).toLowerCase() : t; + + return { + sessionID: s.session_id, + type: cap as SessionInfoType, + }; + }); + + return { + userID: base.user_id, + timeStarted: ISO8601ToUnixMilliseconds(base.time_started), + timeFinished: ISO8601ToUnixMilliseconds(base.time_finished), + gptStrings, + importID: base.id, + scoreIDs: scoreIds.map((s) => s.id), + game: base.game_group, + playtypes: [...playtypeSet] as ImportDocument["playtypes"], + errors: errors.map((e) => ({ type: e.type, message: e.message })), + createdSessions, + importType: base.import_type as ImportTypes, + classDeltas, + goalInfo: [], + questInfo: [], + userIntent: base.user_intent, + }; +} + +type SessionInfoType = ImportDocument["createdSessions"][number]["type"]; diff --git a/typescript/server/src/lib/db-formats/score.ts b/typescript/server/src/lib/db-formats/score.ts new file mode 100644 index 000000000..7a1633c14 --- /dev/null +++ b/typescript/server/src/lib/db-formats/score.ts @@ -0,0 +1,104 @@ +import { mergeScoreDataFromPg } from "#lib/v3/migration-tools"; +import DB from "#services/pg/db"; +import { ISO8601ToUnixMilliseconds } from "#utils/time"; +import { + GetGPTString, + type GPTString, + type ImportTypes, + type ScoreDocument, + V3ToGamePT, +} from "tachi-common"; +import { type Game } from "tachi-db"; + +/** Columns from `score` joined with chart/song for a full {@link ScoreDocument}. */ +export const SELECT_SCORE_DOCUMENT = [ + "score.id", + "score.user_id", + "score.game", + "score.data", + "score.derived_data", + "score.calculated_data", + "score.meta", + "score.time_achieved", + "score.time_added", + "score.highlight", + "score.comment", + "chart.legacy_id as chart_legacy_id", + "chart.is_primary", + "song.legacy_id as song_legacy_id", + "import.service as import_service", + "import.import_type as import_import_type", +] as const; + +/** Row shape from {@link SELECT_SCORE_DOCUMENT} join query. */ +export interface ScoreDocumentJoinRow { + id: string; + user_id: number; + game: Game; + data: unknown; + derived_data: unknown; + calculated_data: unknown; + meta: unknown; + time_achieved: string | null; + time_added: string; + highlight: boolean; + comment: string | null; + chart_legacy_id: string; + is_primary: boolean; + song_legacy_id: number; + import_service: string | null; + import_import_type: string | null; +} + +export function ToScoreDocument(row: ScoreDocumentJoinRow): ScoreDocument { + const { game, playtype } = V3ToGamePT(row.game); + const gpt = GetGPTString(game, playtype) as GPTString; + + const scoreData = mergeScoreDataFromPg(gpt, row.data, row.derived_data); + + const scoreMeta = row.meta as ScoreDocument["scoreMeta"]; + + const calculatedData = row.calculated_data as ScoreDocument["calculatedData"]; + + const service = + row.import_service !== null && + row.import_service !== undefined && + row.import_service.length > 0 + ? row.import_service + : "Unknown"; + + return { + service, + game, + playtype, + userID: row.user_id, + scoreData, + scoreMeta: scoreMeta ?? {}, + calculatedData: calculatedData ?? {}, + timeAchieved: row.time_achieved ? ISO8601ToUnixMilliseconds(row.time_achieved) : null, + songID: row.song_legacy_id, + chartID: row.chart_legacy_id, + isPrimary: row.is_primary, + highlight: row.highlight, + comment: row.comment, + timeAdded: ISO8601ToUnixMilliseconds(row.time_added), + scoreID: row.id, + importType: row.import_import_type as ImportTypes | null, + }; +} + +export async function LoadScoreDocumentById(scoreID: string): Promise { + const row = await DB.selectFrom("score") + .innerJoin("chart", "chart.id", "score.chart_id") + .innerJoin("song", "song.id", "chart.song_id") + .leftJoin("import", "import.id", "score.import_id") + .select(SELECT_SCORE_DOCUMENT) + .where("score.id", "=", scoreID) + .executeTakeFirst(); + + if (!row) { + return undefined; + } + + return ToScoreDocument(row as ScoreDocumentJoinRow); +} diff --git a/typescript/server/src/lib/db-formats/session.full.test.ts b/typescript/server/src/lib/db-formats/session.full.test.ts new file mode 100644 index 000000000..0937cc45e --- /dev/null +++ b/typescript/server/src/lib/db-formats/session.full.test.ts @@ -0,0 +1,92 @@ +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { describe, expect, it } from "vitest"; + +import { LoadSessionDocumentById } from "./session"; + +describe("LoadSessionDocumentById", () => { + it("returns undefined when session is missing", async () => { + expect(await LoadSessionDocumentById("no-such-session")).toBeUndefined(); + }); + + it("maps session row and score IDs", async () => { + const { id: userId } = await seedUser(); + const sessionId = `sess-full-${Date.now()}`; + const now = new Date().toISOString(); + + await DB.insertInto("session") + .values({ + id: sessionId, + user_id: userId, + game: "iidx-sp", + name: "N", + description: null, + time_inserted: now, + time_started: now, + time_ended: now, + calculated_data: JSON.stringify({}), + highlight: false, + }) + .execute(); + + const songId = `s-${sessionId}`; + const chartId = `c-${sessionId}`; + + await DB.insertInto("song") + .values({ + id: songId, + legacy_id: 1, + game_group: "iidx", + title: "T", + artist: "A", + data: JSON.stringify({}), + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id: chartId, + legacy_id: chartId, + game: "iidx-sp", + song_id: songId, + level: "1", + level_num: 1, + is_primary: true, + difficulty: "NORMAL", + data: JSON.stringify({}), + }) + .execute(); + + const scoreId = `sc-${sessionId}`; + + await DB.insertInto("score") + .values({ + id: scoreId, + user_id: userId, + chart_id: chartId, + game: "iidx-sp", + session_id: sessionId, + import_id: null, + data: JSON.stringify({}), + derived_data: JSON.stringify({}), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: null, + time_added: now, + highlight: false, + comment: null, + }) + .execute(); + + const doc = await LoadSessionDocumentById(sessionId); + + expect(doc).toMatchObject({ + sessionID: sessionId, + userID: userId, + name: "N", + scoreIDs: [scoreId], + game: "iidx", + playtype: "SP", + }); + }); +}); diff --git a/typescript/server/src/lib/db-formats/session.ts b/typescript/server/src/lib/db-formats/session.ts index 47e0f6f28..38fbd37cc 100644 --- a/typescript/server/src/lib/db-formats/session.ts +++ b/typescript/server/src/lib/db-formats/session.ts @@ -1,8 +1,72 @@ +import DB from "#services/pg/db"; import { ISO8601ToUnixMilliseconds } from "#utils/time"; import { type Selection } from "kysely"; -import { type GPTString, type SessionDocument, V3ToGamePT } from "tachi-common"; +import { type SessionDocument, V3ToGamePT } from "tachi-common"; import { type Database } from "tachi-db"; +export const SELECT_SESSION_DOCUMENT = [ + "session.id", + "session.user_id", + "session.game", + "session.name", + "session.description", + "session.time_inserted", + "session.time_started", + "session.time_ended", + "session.calculated_data", + "session.highlight", +] as const; + +export function ToSessionDocument( + row: Selection, + scoreIDs: Array, +): SessionDocument { + const { game, playtype } = V3ToGamePT(row.game); + + const calculatedData = + typeof row.calculated_data === "string" + ? (JSON.parse(row.calculated_data) as SessionDocument["calculatedData"]) + : (row.calculated_data as SessionDocument["calculatedData"]); + + return { + userID: row.user_id, + sessionID: row.id, + scoreIDs, + name: row.name, + desc: row.description, + game, + playtype, + timeInserted: ISO8601ToUnixMilliseconds(row.time_inserted), + timeStarted: ISO8601ToUnixMilliseconds(row.time_started), + timeEnded: ISO8601ToUnixMilliseconds(row.time_ended), + calculatedData, + highlight: row.highlight, + }; +} + +export async function LoadSessionDocumentById( + sessionID: string, +): Promise { + const row = await DB.selectFrom("session") + .select(SELECT_SESSION_DOCUMENT) + .where("id", "=", sessionID) + .executeTakeFirst(); + + if (!row) { + return undefined; + } + + const scores = await DB.selectFrom("score") + .select("id") + .where("session_id", "=", sessionID) + .execute(); + + return ToSessionDocument( + row, + scores.map((s) => s.id), + ); +} + export const SELECT_SESSION_CALENDAR = [ "session.id", "session.name", diff --git a/typescript/server/src/lib/db-formats/song.ts b/typescript/server/src/lib/db-formats/song.ts index 4abb43ed0..77e7914f1 100644 --- a/typescript/server/src/lib/db-formats/song.ts +++ b/typescript/server/src/lib/db-formats/song.ts @@ -25,10 +25,7 @@ export async function GetSongByLegacyID( .select("search_term") .where("song_id", "=", row.id) .execute(), - DB.selectFrom("song_alt_title") - .select("alt_title") - .where("song_id", "=", row.id) - .execute(), + DB.selectFrom("song_alt_title").select("alt_title").where("song_id", "=", row.id).execute(), ]); const doc: SongDocument = { diff --git a/typescript/server/src/lib/db-formats/table.ts b/typescript/server/src/lib/db-formats/table.ts new file mode 100644 index 000000000..7ca9e4d98 --- /dev/null +++ b/typescript/server/src/lib/db-formats/table.ts @@ -0,0 +1,63 @@ +import DB from "#services/pg/db"; +import { toPgGame } from "#services/pg/seeds"; +import { type Selectable } from "kysely"; +import { type GameGroup, type Playtype, type TableDocument, V3ToGamePT } from "tachi-common"; +import { type Database } from "tachi-db"; + +export type TableRow = Selectable; + +export function ToTableDocument(row: TableRow, folderIds: Array): TableDocument { + const { game, playtype } = V3ToGamePT(row.game); + + return { + tableID: row.legacy_id, + game, + playtype, + title: row.title, + description: "", + folders: folderIds, + inactive: row.inactive, + default: row.default_value, + }; +} + +/** + * Load table documents for a game/playtype (API shape). No folder *documents* — only folder IDs. + */ +export async function GetTableDocumentsForGamePlaytype( + game: GameGroup, + playtype: Playtype, + includeInactive: boolean, +): Promise> { + const pgGame = toPgGame(game, playtype); + + let q = DB.selectFrom("table").selectAll().where("game", "=", pgGame); + + if (!includeInactive) { + q = q.where("inactive", "=", false); + } + + const rows = await q.execute(); + + if (rows.length === 0) { + return []; + } + + const tableIds = rows.map((r) => r.id); + + const tfRows = await DB.selectFrom("table_folder") + .select(["table_id", "folder_id"]) + .where("table_id", "in", tableIds) + .execute(); + + const foldersByTable = new Map>(); + + for (const t of tfRows) { + const list = foldersByTable.get(t.table_id) ?? []; + + list.push(t.folder_id); + foldersByTable.set(t.table_id, list); + } + + return rows.map((row) => ToTableDocument(row, foldersByTable.get(row.id) ?? [])); +} diff --git a/typescript/server/src/lib/score-import/framework/score-importing/derivers.ts b/typescript/server/src/lib/score-import/framework/score-importing/derivers.ts index 8f0d9caa3..d919f38a5 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/derivers.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/derivers.ts @@ -10,7 +10,7 @@ import { type GPTString, type integer, type OptionalEnumIndexes, - type ScoreData, + type MongoScoreData, type ScoreEnumIndexes, } from "tachi-common"; @@ -129,7 +129,7 @@ export function CreateFullScoreData( const scoreData = { ...dryScoreData, ...derivedMetrics, - } as unknown as ScoreData; + } as unknown as MongoScoreData; // ^ hacky force-cast because these types are *really* unstable. const { indexes, optionalIndexes } = CreateEnumIndexes(gpt, scoreData, log); diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/converter.ts b/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/converter.ts index 4dce5edea..fcdb3ea19 100644 --- a/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/converter.ts @@ -1,6 +1,6 @@ import type { DryScore } from "#lib/score-import/framework/common/types"; import type { EmptyObject } from "#utils/types"; -import type { ScoreData } from "tachi-common"; +import type { MongoScoreData } from "tachi-common"; import { InternalFailure, @@ -29,7 +29,7 @@ const DIFFICULTIES = { function getLamp( comboStatus: number, isClear: boolean, -): ScoreData<"maimaidx:Single">["lamp"] | undefined { +): MongoScoreData<"maimaidx:Single">["lamp"] | undefined { if (comboStatus === MaimaiComboStatus.UNSPECIFIED) { return undefined; } diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/converter.ts b/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/converter.ts index d669fe623..ac82f3859 100644 --- a/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/converter.ts @@ -1,6 +1,6 @@ import type { DryScore } from "#lib/score-import/framework/common/types"; import type { EmptyObject } from "#utils/types"; -import type { ScoreData } from "tachi-common"; +import type { MongoScoreData } from "tachi-common"; import { InternalFailure, @@ -32,7 +32,7 @@ function getNoteLamp( comboStatus: number, clearStatus: number, techScore: number, -): ScoreData<"ongeki:Single">["noteLamp"] | undefined { +): MongoScoreData<"ongeki:Single">["noteLamp"] | undefined { if ( comboStatus === OngekiComboStatus.UNSPECIFIED || clearStatus === OngekiClearStatus.UNSPECIFIED diff --git a/typescript/server/src/lib/v3/migration-tools.test.ts b/typescript/server/src/lib/v3/migration-tools.test.ts new file mode 100644 index 000000000..da15c14ea --- /dev/null +++ b/typescript/server/src/lib/v3/migration-tools.test.ts @@ -0,0 +1,38 @@ +import { SDVX_GRADES, SDVX_LAMPS, type MongoScoreData } from "tachi-common"; +import { describe, expect, it } from "vitest"; + +import { mergeScoreDataFromPg, mongoScoreDataToPg } from "./migration-tools.js"; + +describe("mergeScoreDataFromPg", () => { + const scoreData = { + score: 9_876_543, + lamp: "EXCESSIVE CLEAR", + grade: "S", + optional: { + enumIndexes: {}, + exScore: 123, + fast: 32, + gauge: 99, + maxCombo: 9, + slow: 42, + }, + enumIndexes: { + grade: SDVX_GRADES.S, + lamp: SDVX_LAMPS.EXCESSIVE_CLEAR, + }, + judgements: { + critical: 123, + miss: 23, + near: 22, + }, + } satisfies MongoScoreData<"sdvx:Single">; + + it("roundtrips as expected (sdvx:Single)", () => { + const gpt = "sdvx:Single"; + + const { data, derived } = mongoScoreDataToPg(gpt, scoreData); + const merged = mergeScoreDataFromPg(gpt, data, derived); + + expect(merged).toStrictEqual(scoreData); + }); +}); diff --git a/typescript/server/src/lib/v3/migration-tools.ts b/typescript/server/src/lib/v3/migration-tools.ts index 49f4b3a09..61e91863b 100644 --- a/typescript/server/src/lib/v3/migration-tools.ts +++ b/typescript/server/src/lib/v3/migration-tools.ts @@ -1,10 +1,15 @@ +import { DeleteUndefinedProps, staticAssertUnreachable } from "#utils/misc.js"; +import { Mongos } from "mongodb"; import { type DerivedMetrics, GetGPTConfig, type GPTString, type OptionalMetrics, type ProvidedMetrics, - type ScoreData, + type MongoScoreData, + PgScoreData, + GPTStringToV3Game, + integer, } from "tachi-common"; interface RetVal { @@ -36,11 +41,11 @@ function applyOrdinals( return result; } -function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal { +function splitScoreData(gpt: GPTString, scoreData: MongoScoreData): RetVal { switch (gpt) { case "iidx:SP": { const { grade, lamp, percent, score, optional } = - scoreData as unknown as ScoreData<"iidx:SP">; + scoreData as unknown as MongoScoreData<"iidx:SP">; const ret: RetVal<"iidx:SP"> = { data: { @@ -59,7 +64,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "iidx:DP": { const { grade, lamp, percent, score, optional } = - scoreData as unknown as ScoreData<"iidx:DP">; + scoreData as unknown as MongoScoreData<"iidx:DP">; const ret: RetVal<"iidx:DP"> = { data: { @@ -75,7 +80,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "museca:Single": { const { grade, lamp, score, optional } = - scoreData as unknown as ScoreData<"museca:Single">; + scoreData as unknown as MongoScoreData<"museca:Single">; const ret: RetVal<"museca:Single"> = { data: { @@ -91,7 +96,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "chunithm:Single": { const { grade, score, noteLamp, clearLamp, optional } = - scoreData as unknown as ScoreData<"chunithm:Single">; + scoreData as unknown as MongoScoreData<"chunithm:Single">; const ret: RetVal<"chunithm:Single"> = { data: { @@ -108,7 +113,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "bms:7K": { const { grade, lamp, percent, score, optional } = - scoreData as unknown as ScoreData<"bms:7K">; + scoreData as unknown as MongoScoreData<"bms:7K">; const ret: RetVal<"bms:7K"> = { data: { @@ -124,7 +129,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "bms:14K": { const { grade, lamp, percent, score, optional } = - scoreData as unknown as ScoreData<"bms:14K">; + scoreData as unknown as MongoScoreData<"bms:14K">; const ret: RetVal<"bms:14K"> = { data: { @@ -140,7 +145,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "gitadora:Gita": { const { grade, lamp, percent, optional } = - scoreData as unknown as ScoreData<"gitadora:Gita">; + scoreData as unknown as MongoScoreData<"gitadora:Gita">; const ret: RetVal<"gitadora:Gita"> = { data: { @@ -156,7 +161,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "gitadora:Dora": { const { grade, lamp, percent, optional } = - scoreData as unknown as ScoreData<"gitadora:Dora">; + scoreData as unknown as MongoScoreData<"gitadora:Dora">; const ret: RetVal<"gitadora:Dora"> = { data: { @@ -172,7 +177,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "jubeat:Single": { const { grade, score, musicRate, lamp, optional } = - scoreData as unknown as ScoreData<"jubeat:Single">; + scoreData as unknown as MongoScoreData<"jubeat:Single">; const ret: RetVal<"jubeat:Single"> = { data: { @@ -189,7 +194,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "maimai:Single": { const { grade, lamp, percent, optional } = - scoreData as unknown as ScoreData<"maimai:Single">; + scoreData as unknown as MongoScoreData<"maimai:Single">; const ret: RetVal<"maimai:Single"> = { data: { @@ -205,7 +210,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "maimaidx:Single": { const { grade, lamp, percent, optional } = - scoreData as unknown as ScoreData<"maimaidx:Single">; + scoreData as unknown as MongoScoreData<"maimaidx:Single">; const ret: RetVal<"maimaidx:Single"> = { data: { @@ -221,7 +226,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "popn:9B": { const { grade, lamp, score, clearMedal, optional } = - scoreData as unknown as ScoreData<"popn:9B">; + scoreData as unknown as MongoScoreData<"popn:9B">; const ret: RetVal<"popn:9B"> = { data: { @@ -237,7 +242,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "sdvx:Single": { const { grade, lamp, score, optional } = - scoreData as unknown as ScoreData<"sdvx:Single">; + scoreData as unknown as MongoScoreData<"sdvx:Single">; const ret: RetVal<"sdvx:Single"> = { data: { @@ -253,7 +258,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "usc:Controller": { const { grade, lamp, score, optional } = - scoreData as unknown as ScoreData<"usc:Controller">; + scoreData as unknown as MongoScoreData<"usc:Controller">; const ret: RetVal<"usc:Controller"> = { data: { @@ -269,7 +274,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "usc:Keyboard": { const { grade, lamp, score, optional } = - scoreData as unknown as ScoreData<"usc:Keyboard">; + scoreData as unknown as MongoScoreData<"usc:Keyboard">; const ret: RetVal<"usc:Keyboard"> = { data: { @@ -285,7 +290,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "wacca:Single": { const { grade, lamp, score, optional } = - scoreData as unknown as ScoreData<"wacca:Single">; + scoreData as unknown as MongoScoreData<"wacca:Single">; const ret: RetVal<"wacca:Single"> = { data: { @@ -301,7 +306,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "pms:Controller": { const { grade, lamp, percent, score, optional } = - scoreData as unknown as ScoreData<"pms:Controller">; + scoreData as unknown as MongoScoreData<"pms:Controller">; const ret: RetVal<"pms:Controller"> = { data: { @@ -317,7 +322,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "pms:Keyboard": { const { grade, lamp, percent, score, optional } = - scoreData as unknown as ScoreData<"pms:Keyboard">; + scoreData as unknown as MongoScoreData<"pms:Keyboard">; const ret: RetVal<"pms:Keyboard"> = { data: { @@ -333,7 +338,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "itg:Stamina": { const { grade, lamp, scorePercent, survivedPercent, finalPercent, optional } = - scoreData as unknown as ScoreData<"itg:Stamina">; + scoreData as unknown as MongoScoreData<"itg:Stamina">; const ret: RetVal<"itg:Stamina"> = { data: { @@ -350,7 +355,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "arcaea:Touch": { const { grade, lamp, score, optional } = - scoreData as unknown as ScoreData<"arcaea:Touch">; + scoreData as unknown as MongoScoreData<"arcaea:Touch">; const ret: RetVal<"arcaea:Touch"> = { data: { @@ -366,7 +371,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal case "ongeki:Single": { const { grade, score, noteLamp, bellLamp, platinumScore, platinumStars, optional } = - scoreData as unknown as ScoreData<"ongeki:Single">; + scoreData as unknown as MongoScoreData<"ongeki:Single">; const ret: RetVal<"ongeki:Single"> = { data: { @@ -383,7 +388,8 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal } case "ddr:SP": { - const { grade, lamp, score, optional } = scoreData as unknown as ScoreData<"ddr:SP">; + const { grade, lamp, score, optional } = + scoreData as unknown as MongoScoreData<"ddr:SP">; const ret: RetVal<"ddr:SP"> = { data: { @@ -398,7 +404,8 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal } case "ddr:DP": { - const { grade, lamp, score, optional } = scoreData as unknown as ScoreData<"ddr:DP">; + const { grade, lamp, score, optional } = + scoreData as unknown as MongoScoreData<"ddr:DP">; const ret: RetVal<"ddr:DP"> = { data: { @@ -416,7 +423,7 @@ function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal export function mongoScoreDataToPg( gpt: GPTString, - scoreData: ScoreData, + scoreData: MongoScoreData, ): { data: unknown; derived: unknown } { const { data, derived } = splitScoreData(gpt, scoreData); @@ -427,3 +434,62 @@ export function mongoScoreDataToPg( derived: applyOrdinals(derived, config.derivedMetrics as any), }; } + +/** + * Reconstruct API {@link MongoScoreData} from Postgres `data` / `derived_data` JSON blobs + * (the inverse of {@link mongoScoreDataToPg}). + */ +export function mergeScoreDataFromPg( + gpt: G, + dataRaw: any, + derivedRaw: any, +): MongoScoreData { + const config = GetGPTConfig(gpt); + + const eventualOut: any = { + enumIndexes: {}, + optional: { + enumIndexes: {}, + }, + }; + + for (const [key, value] of Object.entries(config.providedMetrics)) { + if (value.type === "ENUM") { + const index: integer = dataRaw[key]; + eventualOut[key] = value.values[index]; + eventualOut.enumIndexes[key] = index; + } else { + eventualOut[key] = dataRaw[key]; + } + } + + console.log(dataRaw); + console.log(derivedRaw); + + for (const [key, value] of Object.entries(config.derivedMetrics)) { + if (value.type === "ENUM") { + const index: integer = derivedRaw[key]; + eventualOut[key] = value.values[index]; + eventualOut.enumIndexes[key] = index; + } else { + eventualOut[key] = derivedRaw[key]; + } + } + + for (const [key, value] of Object.entries(config.optionalMetrics)) { + if (value.type === "ENUM") { + const index: integer = dataRaw[key]; + eventualOut.optional[key] = value.values[index]; + eventualOut.optional.enumIndexes[key] = index; + } else { + eventualOut.optional[key] = dataRaw[key]; + } + } + + console.log(eventualOut); + + DeleteUndefinedProps(eventualOut.optional); + DeleteUndefinedProps(eventualOut); + + return eventualOut as any; +} diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts index 190a6c27d..e3e1e435b 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts +++ b/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts @@ -1,11 +1,10 @@ -import type { FilterQuery } from "mongodb"; - import { log } from "#lib/log/log"; +import { GetTableDocumentsForGamePlaytype } from "#lib/db-formats/table"; import MONGODB_KILL from "#services/mongo/db"; import { GetFoldersFromTable } from "#utils/folder"; import { GetGPT, GetTachiData } from "#utils/req-tachi-data"; import { Router } from "express"; -import { FormatGameGroup, type TableDocument } from "tachi-common"; +import { FormatGameGroup } from "tachi-common"; import { GetTableFromParam } from "./middleware"; @@ -21,13 +20,9 @@ const router: Router = Router({ mergeParams: true }); router.get("/", async (req, res) => { const { game, playtype } = GetGPT(req); - const query: FilterQuery = { game, playtype }; + const includeInactive = req.query.showInactive !== undefined; - if (req.query.showInactive === undefined) { - query.inactive = false; - } - - const tables = await MONGODB_KILL.tables.find(query); + const tables = await GetTableDocumentsForGamePlaytype(game, playtype, includeInactive); if (tables.length === 0) { log.error( diff --git a/typescript/server/src/server/router/api/v1/imports/middleware.ts b/typescript/server/src/server/router/api/v1/imports/middleware.ts index 326ab0fc2..33e6cdef7 100644 --- a/typescript/server/src/server/router/api/v1/imports/middleware.ts +++ b/typescript/server/src/server/router/api/v1/imports/middleware.ts @@ -2,12 +2,12 @@ import type { RequestHandler } from "express"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { log } from "#lib/log/log"; -import MONGODB_KILL from "#services/mongo/db"; +import { LoadImportDocumentById } from "#lib/db-formats/import-document"; import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; import { IsRequesterAdmin } from "#utils/user"; export const GetImportFromParam: RequestHandler = async (req, res, next) => { - const importDoc = await MONGODB_KILL.imports.findOne({ importID: req.params.importID }); + const importDoc = await LoadImportDocumentById(req.params.importID); if (!importDoc) { return res.status(404).json({ diff --git a/typescript/server/src/server/router/api/v1/scores/_scoreID/middleware.ts b/typescript/server/src/server/router/api/v1/scores/_scoreID/middleware.ts index 3bb05e0b5..9d5b48dd8 100644 --- a/typescript/server/src/server/router/api/v1/scores/_scoreID/middleware.ts +++ b/typescript/server/src/server/router/api/v1/scores/_scoreID/middleware.ts @@ -2,12 +2,12 @@ import type { RequestHandler } from "express"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { log } from "#lib/log/log"; -import MONGODB_KILL from "#services/mongo/db"; +import { LoadScoreDocumentById } from "#lib/db-formats/score"; import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; import { IsRequesterAdmin } from "#utils/user"; export const GetScoreFromParam: RequestHandler = async (req, res, next) => { - const score = await MONGODB_KILL.scores.findOne({ scoreID: req.params.scoreID }); + const score = await LoadScoreDocumentById(req.params.scoreID); if (!score) { return res.status(404).json({ diff --git a/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts b/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts index 15e386c6c..a1d9d503b 100644 --- a/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts +++ b/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts @@ -1,8 +1,12 @@ +import { ACTION_CustomiseScore } from "#actions/customise-score"; +import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; +import { GetChartsBySongPgId } from "#lib/db-formats/chart"; +import { GetSongByLegacyID } from "#lib/db-formats/song"; import { log } from "#lib/log/log"; import { DeleteScore } from "#lib/score-mutation/delete-scores"; import { RequirePermissions } from "#server/middleware/auth"; import prValidate from "#server/middleware/prudence-validate"; -import MONGODB_KILL from "#services/mongo/db"; +import { toPgGame } from "#services/pg/seeds"; import { GetTachiData } from "#utils/req-tachi-data"; import { GetUserWithID } from "#utils/user"; import { Router } from "express"; @@ -25,11 +29,21 @@ router.get("/", async (req, res) => { const score = GetTachiData(req, "scoreDoc"); if (req.query.getRelated !== undefined) { - const [user, chart, song] = await Promise.all([ - GetUserWithID(score.userID), - MONGODB_KILL.anyCharts[score.game].findOne({ chartID: score.chartID }), - MONGODB_KILL.anySongs[score.game].findOne({ id: score.songID }), - ]); + const user = await GetUserWithID(score.userID); + + const songRes = await GetSongByLegacyID(score.game, score.songID); + + const charts = + songRes === undefined + ? [] + : await GetChartsBySongPgId( + toPgGame(score.game, score.playtype), + songRes.pgId, + score.songID, + ); + + const chart = charts.find((c) => c.chartID === score.chartID); + const song = songRes?.doc; if (!user || !chart || !song) { log.error( @@ -110,29 +124,35 @@ router.patch( }); } - const newScore = await MONGODB_KILL.scores.findOneAndUpdate( - { scoreID: score.scoreID }, - { $set: modifyOption }, - ); + const auth = req[SYMBOL_TACHI_API_AUTH]; - if (modifyOption.highlight === true || modifyOption.highlight === false) { - await MONGODB_KILL["personal-bests"].findOneAndUpdate( - { - chartID: score.chartID, - userID: score.userID, - }, - { - $set: { - highlight: modifyOption.highlight, - }, - }, - ); + if (auth.userID === null) { + return res.status(401).json({ + success: false, + description: `You are not authorised as anyone, and this endpoint requires us to know who you are.`, + }); } + const user = await GetUserWithID(auth.userID); + + if (!user) { + return res.status(401).json({ + success: false, + description: `You are not authorised as anyone, and this endpoint requires us to know who you are.`, + }); + } + + const taker = { ip: req.ip, acct: { id: user.id, username: user.username } }; + + const result = await ACTION_CustomiseScore(taker, { + scoreID: score.scoreID, + ...modifyOption, + }); + return res.status(200).json({ success: true, description: `Updated score.`, - body: newScore, + body: result.score, }); }, ); diff --git a/typescript/server/src/server/router/api/v1/sessions/_sessionID/middleware.ts b/typescript/server/src/server/router/api/v1/sessions/_sessionID/middleware.ts index c87f8b780..68da7fd0b 100644 --- a/typescript/server/src/server/router/api/v1/sessions/_sessionID/middleware.ts +++ b/typescript/server/src/server/router/api/v1/sessions/_sessionID/middleware.ts @@ -2,13 +2,11 @@ import type { RequestHandler } from "express"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { log } from "#lib/log/log"; -import MONGODB_KILL from "#services/mongo/db"; +import { LoadSessionDocumentById } from "#lib/db-formats/session"; import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; export const GetSessionFromParam: RequestHandler = async (req, res, next) => { - const session = await MONGODB_KILL.sessions.findOne({ - sessionID: req.params.sessionID, - }); + const session = await LoadSessionDocumentById(req.params.sessionID); if (!session) { return res.status(404).json({ diff --git a/typescript/server/src/server/router/api/v1/users/_userID/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/router.ts index 3889f4525..74ac9f262 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/router.ts @@ -10,7 +10,6 @@ import { SELECT_GAME_STATS, ToGameStatsDocument } from "#lib/db-formats/game-sta import { log } from "#lib/log/log"; import { GetRivalIDs } from "#lib/rivals/rivals"; import prValidate from "#server/middleware/prudence-validate"; -import MONGODB_KILL from "#services/mongo/db"; import DB from "#services/pg/db.js"; import { optNullFluffStrField } from "#utils/prudence"; import { @@ -25,6 +24,7 @@ import { FormatUserDoc, GetAllRankings, GetNextAvailableUsernameChange, + GetSettingsForUser, GetUserWithIDGuaranteed, } from "#utils/user"; import { Router } from "express"; @@ -567,21 +567,16 @@ router.get( const user = GetUser(req); - const gpts = await MONGODB_KILL["game-stats"].find({ - userID: user.id, - }); + const gameStatRows = await DB.selectFrom("game_stats") + .select(SELECT_GAME_STATS) + .where("user_id", "=", user.id) + .execute(); + + const gpts = gameStatRows.map(ToGameStatsDocument); const data: Partial> = {}; - const settings = await MONGODB_KILL["user-settings"].findOne({ userID: user.id }); - - if (!settings) { - log.error(`User ${FormatUserDoc(user)} doesn't have any settings?`); - return res.status(500).json({ - success: false, - description: `This user has no settings.`, - }); - } + const settings = await GetSettingsForUser(user.id); await Promise.all( gpts.map(async (e) => { diff --git a/typescript/server/src/server/router/ir/kshook/sv6c/router.test.ts b/typescript/server/src/server/router/ir/kshook/sv6c/router.test.ts index 4d013eed1..cc4755716 100644 --- a/typescript/server/src/server/router/ir/kshook/sv6c/router.test.ts +++ b/typescript/server/src/server/router/ir/kshook/sv6c/router.test.ts @@ -12,10 +12,7 @@ import DB from "#services/pg/db"; import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; import { seedUser } from "#test-utils/pg-fixtures"; import ResetDBState from "#test-utils/resets"; -import { - TestingKsHookSV6CScore, - TestingKsHookSV6CStaticScore, -} from "#test-utils/test-data"; +import { TestingKsHookSV6CScore, TestingKsHookSV6CStaticScore } from "#test-utils/test-data"; import deepmerge from "deepmerge"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; @@ -192,7 +189,9 @@ describe("POST /ir/kshook/sv6c/score/export", () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); - expect(res.body.description).toBe("Static importing is disabled. Ignoring static import request."); + expect(res.body.description).toBe( + "Static importing is disabled. Ignoring static import request.", + ); }); it("rejects invalid software models and missing header", async () => { diff --git a/typescript/server/src/test-utils/misc.ts b/typescript/server/src/test-utils/misc.ts index 3f1c199f4..b0cfa90b4 100644 --- a/typescript/server/src/test-utils/misc.ts +++ b/typescript/server/src/test-utils/misc.ts @@ -10,7 +10,7 @@ import type { NotificationDocument, PBScoreDocument, Playtype, - ScoreData, + MongoScoreData, ScoreDocument, UGPTSettingsDocument, UserDocument, @@ -159,7 +159,7 @@ export function mkMockPB( game: GameGroup, playtype: Playtype, chart: ChartDocument, - scoreData: ScoreData, + scoreData: MongoScoreData, ): PBScoreDocument { return { userID: 1, @@ -181,7 +181,7 @@ export function mkMockScore( game: GameGroup, playtype: Playtype, chart: ChartDocument, - scoreData: ScoreData, + scoreData: MongoScoreData, ): ScoreDocument { // @ts-expect-error whatever lol return {