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
@@ -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 {