checkpoint

This commit is contained in:
zk
2026-03-28 13:14:59 +00:00
parent 6f33a1d27b
commit dda54da3bc
36 changed files with 1090 additions and 134 deletions
+60
View File
@@ -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`).
+1 -1
View File
@@ -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.
+2
View File
@@ -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,
@@ -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;
}) {
@@ -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;
}) {
@@ -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;
}) {
+8 -3
View File
@@ -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<GPT extends GPTString = GPTString> = {
export type PgScoreData<Game extends V3Game = V3Game> = ProvidedMetrics[V3GameToGPTString[Game]] &
DerivedMetrics[V3GameToGPTString[Game]];
export type MongoScoreData<GPT extends GPTString = GPTString> = {
enumIndexes: ScoreEnumIndexes<GPT>;
judgements: Partial<Record<Judgements[GPT], integer | null>>;
optional: {
@@ -456,7 +461,7 @@ export interface ScoreDocument<GPT extends GPTString = GPTString> {
game: GPTStringToGame[GPT];
playtype: GPTStringToPlaytype[GPT];
userID: integer;
scoreData: ScoreData<GPT>;
scoreData: MongoScoreData<GPT>;
scoreMeta: Partial<ScoreMeta[GPT]>;
calculatedData: Partial<Record<ScoreRatingAlgorithms[GPT], number | null>>;
timeAchieved: integer | null;
@@ -496,7 +501,7 @@ export interface PBScoreDocument<GPT extends GPTString = GPTString> {
highlight: boolean;
isPrimary: boolean;
timeAchieved: number | null;
scoreData: ScoreData<GPT>;
scoreData: MongoScoreData<GPT>;
calculatedData: Partial<Record<ScoreRatingAlgorithms[GPT], number | null>>;
}
@@ -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]}`;
};
@@ -29,6 +29,8 @@ export default interface ScoreTable {
calculated_data: ColumnType<unknown, unknown, unknown>;
judgements: ColumnType<unknown, unknown, unknown>;
meta: ColumnType<unknown, unknown, unknown>;
time_achieved: ColumnType<string | null, string | null, string | null>;
@@ -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,
};
});
@@ -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,
});
});
});
@@ -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 };
});
@@ -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;
}
});
@@ -13,7 +13,7 @@ import type {
PBReference,
PBScoreDocument,
ProfileRatingAlgorithms,
ScoreData,
MongoScoreData,
ScoreDocument,
ScoreRatingAlgorithms,
SessionRatingAlgorithms,
@@ -130,14 +130,14 @@ export type GPTDerivers<GPT extends GPTString> = {
// New-style deriver; just f(scoreData, chart) -> derivedMetrics
// instead of the overly complex shit above.
export type GPTNewDeriver<GPT extends GPTString> = (
scoreData: ScoreData<GPT>,
scoreData: MongoScoreData<GPT>,
chart: ChartDocument<GPT>,
) => DerivedMetrics[GPT];
// New-style score calc; just f(scoreData, derivedData, chart) -> calculatedData
// instead of the per-algorithm record above.
export type GPTNewCalcs<GPT extends GPTString> = (
scoreData: ScoreData<GPT>,
scoreData: MongoScoreData<GPT>,
derivedData: DerivedMetrics[GPT],
chart: ChartDocument<GPT>,
) => Record<ScoreRatingAlgorithms[GPT], number | null>;
@@ -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"]),
+17 -2
View File
@@ -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<ChartDocument[]> {
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();
@@ -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");
});
});
@@ -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<ImportDocument | undefined> {
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<GPTString>();
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<ClassDelta> = 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"];
@@ -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<ScoreDocument | undefined> {
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);
}
@@ -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",
});
});
});
@@ -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<Database, "session", (typeof SELECT_SESSION_DOCUMENT)[number]>,
scoreIDs: Array<string>,
): 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<SessionDocument | undefined> {
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",
+1 -4
View File
@@ -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 = {
@@ -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<Database["table"]>;
export function ToTableDocument(row: TableRow, folderIds: Array<string>): 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<Array<TableDocument>> {
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<string, Array<string>>();
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) ?? []));
}
@@ -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<GPT extends GPTString>(
const scoreData = {
...dryScoreData,
...derivedMetrics,
} as unknown as ScoreData<GPT>;
} as unknown as MongoScoreData<GPT>;
// ^ hacky force-cast because these types are *really* unstable.
const { indexes, optionalIndexes } = CreateEnumIndexes(gpt, scoreData, log);
@@ -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;
}
@@ -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
@@ -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);
});
});
+92 -26
View File
@@ -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<GPT extends GPTString> {
@@ -36,11 +41,11 @@ function applyOrdinals(
return result;
}
function splitScoreData(gpt: GPTString, scoreData: ScoreData): RetVal<GPTString> {
function splitScoreData(gpt: GPTString, scoreData: MongoScoreData): RetVal<GPTString> {
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<GPTString>
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<GPTString>
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<GPTString>
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<GPTString>
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<GPTString>
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<GPTString>
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<GPTString>
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<GPTString>
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<GPTString>
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<GPTString>
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<GPTString>
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<GPTString>
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<GPTString>
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<GPTString>
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<GPTString>
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<GPTString>
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<GPTString>
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<GPTString>
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<GPTString>
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<GPTString>
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<GPTString>
}
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<GPTString>
}
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<GPTString>
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<G extends GPTString = GPTString>(
gpt: G,
dataRaw: any,
derivedRaw: any,
): MongoScoreData<G> {
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;
}
@@ -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<TableDocument> = { 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(
@@ -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({
@@ -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({
@@ -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,
});
},
);
@@ -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({
@@ -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<Record<GPTString, unknown>> = {};
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) => {
@@ -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 () => {
+3 -3
View File
@@ -10,7 +10,7 @@ import type {
NotificationDocument,
PBScoreDocument,
Playtype,
ScoreData,
MongoScoreData,
ScoreDocument,
UGPTSettingsDocument,
UserDocument,
@@ -159,7 +159,7 @@ export function mkMockPB<GPT extends GPTString>(
game: GameGroup,
playtype: Playtype,
chart: ChartDocument<GPT>,
scoreData: ScoreData<GPT>,
scoreData: MongoScoreData<GPT>,
): PBScoreDocument<GPT> {
return {
userID: 1,
@@ -181,7 +181,7 @@ export function mkMockScore<GPT extends GPTString>(
game: GameGroup,
playtype: Playtype,
chart: ChartDocument<GPT>,
scoreData: ScoreData<GPT>,
scoreData: MongoScoreData<GPT>,
): ScoreDocument<GPT> {
// @ts-expect-error whatever lol
return {