kill mongodb (#51)

* fool me

* feat: rivals

* refactor: chart document movements

* fix: tests

* feat: probably working goals mig

* feat: kill mongodb
This commit is contained in:
zk
2026-04-03 18:21:05 +01:00
committed by GitHub
parent dd2447c848
commit 71e8aa2152
106 changed files with 3129 additions and 2308 deletions
@@ -0,0 +1,25 @@
---
description: Fully qualify SQL/Kysely column names (table.alias prefix)
globs: typescript/server/**/*.ts
alwaysApply: false
---
# Fully qualified fields in queries
In Kysely `select`, `where`, `orderBy`, `join` conditions, and raw `sql` fragments, **always** qualify column names with the table or join alias. Do not use bare column names when the query references a table (including single-table queries, for consistency and safer refactors).
```typescript
// ❌ BAD — ambiguous once joins or CTEs are added; harder to grep
.select(["data", "chart_id", "user_id"])
.where("id", "=", id)
// ✅ GOOD
.select(["chart.data", "score.chart_id", "score.user_id"])
.where("chart.id", "=", chartId)
```
- Prefer the same alias the query uses in `from` / `innerJoin` (e.g. `chart`, `score`, `song`).
- For `sql` tagged templates, qualify identifiers the same way: `` sql`chart.data` ``, not bare `` sql`data` `` when `data` is a column name.
- When selecting with `as`, keep the qualified source on the left: `"chart.data as chart_data"` (see existing patterns in actions and queries).
This avoids duplicate-column errors, makes intent obvious in code review, and matches how API-facing selects are written alongside `db-formats` column lists.
+1 -1
View File
@@ -68,7 +68,7 @@ test-typescript FILTER="*":
#!/bin/bash
set -euo pipefail
bun run --elide-lines=0 --sequential --filter '{{FILTER}}' test -- --coverage.reporter=lcov --reporter=default --reporter=junit --outputFile.junit=test-results/junit.xml
bun run --elide-lines=0 --sequential --filter '{{FILTER}}' test -- --coverage --coverage.reporter=lcov --reporter=default --reporter=junit --outputFile.junit=test-results/junit.xml
# Run all parity suites, or a single one if a name is given.
+1 -1
View File
@@ -23243,7 +23243,7 @@
"title": "Timepiece phase Ⅱ"
},
{
"altTitles": [],
"altTitles": ["¡¡Fanta∽cramble!!"],
"artist": "Potwi",
"data": {
"displayVersion": "exceed"
@@ -2,9 +2,9 @@
// This file is automatically generated by Kanel. Do not modify manually.
import type { account_id } from './Account';
import type { default as GameGroup } from './GameGroup';
import type { import_id } from './Import';
import type { default as ImportType } from './ImportType';
import type { default as GameGroup } from './GameGroup';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.orphan_score */
@@ -12,25 +12,25 @@ export type orphan_score_row_id = string;
/** Represents the table public.orphan_score */
export default interface OrphanScoreTable {
row_id: ColumnType<orphan_score_row_id, orphan_score_row_id | undefined, orphan_score_row_id>;
row_id: ColumnType<orphan_score_row_id, orphan_score_row_id | undefined, orphan_score_row_id>;
orphan_id: ColumnType<string, string, string>;
user_id: ColumnType<account_id, account_id, account_id>;
user_id: ColumnType<account_id, account_id, account_id>;
import_id: ColumnType<import_id | null, import_id | null, import_id | null>;
import_id: ColumnType<import_id | null, import_id | null | undefined, import_id | null>;
orphan_id: ColumnType<string, string, string>;
import_type: ColumnType<ImportType, ImportType, ImportType>;
import_type: ColumnType<ImportType, ImportType, ImportType>;
game_group: ColumnType<GameGroup, GameGroup, GameGroup>;
game_group: ColumnType<GameGroup, GameGroup, GameGroup>;
data: ColumnType<unknown, unknown, unknown>;
data: ColumnType<unknown, unknown, unknown>;
context: ColumnType<unknown, unknown, unknown>;
context: ColumnType<unknown, unknown, unknown>;
time_inserted: ColumnType<string, string, string>;
time_inserted: ColumnType<string, string, string>;
error_message: ColumnType<string, string, string>;
error_message: ColumnType<string, string, string>;
}
export type OrphanScore = Selectable<OrphanScoreTable>;
@@ -20,8 +20,6 @@ export default interface SessionTable {
description: ColumnType<string | null, string | null, string | null>;
textsearch: ColumnType<string, never, never>;
time_inserted: ColumnType<string, string, string>;
time_started: ColumnType<string, string, string>;
@@ -31,6 +29,8 @@ export default interface SessionTable {
calculated_data: ColumnType<unknown, unknown, unknown>;
highlight: ColumnType<boolean, boolean, boolean>;
textsearch: ColumnType<string, never, never>;
}
export type Session = Selectable<SessionTable>;
@@ -0,0 +1,144 @@
import DB from "#services/pg/db";
import { seedMinimalIidxSpChart, seedUser } from "#test-utils/pg-fixtures";
import { GetGoalForIDGuaranteed, GetGoalSubscriptionForIDGuaranteed } from "#utils/db.js";
import { beforeEach, describe, expect, it } from "vitest";
import { ACTION_AddGoal } from "./add-goal";
describe("ACTION_AddGoal", () => {
let userID: number;
let username: string;
beforeEach(async () => {
({ id: userID, username } = await seedUser({ username: `goal_add_${Date.now()}` }));
});
it("throws 403 when targeting another user as non-admin", async () => {
const other = await seedUser({ username: `goal_add_other_${Date.now()}` });
const taker = { ip: "127.0.0.1", acct: { id: userID, username } };
await expect(
ACTION_AddGoal(taker, {
userID: other.id,
game: "iidx",
playtype: "SP",
charts: { type: "single", data: "chart" },
criteria: { key: "lamp", value: 0, mode: "single" },
}),
).rejects.toMatchObject({ code: 403 });
});
it("inserts goal and goal_sub rows when criteria are valid and the user has no PB", async () => {
const chartId = await seedMinimalIidxSpChart();
const taker = { ip: "127.0.0.1", acct: { id: userID, username } };
const { goalID } = await ACTION_AddGoal(taker, {
userID,
game: "iidx",
playtype: "SP",
charts: { type: "single", data: chartId },
criteria: { key: "lamp", value: 7, mode: "single" },
});
const goal = await GetGoalForIDGuaranteed(goalID);
const goalSub = await GetGoalSubscriptionForIDGuaranteed(goalID, userID);
expect(goalSub).toMatchObject({
goalID,
userID,
});
const goalRow = await DB.selectFrom("goal")
.selectAll()
.where("id", "=", goalID)
.executeTakeFirst();
expect(goalRow).toMatchObject({
id: goal.goalID,
game: "iidx-sp",
name: goal.name,
});
const subRow = await DB.selectFrom("goal_sub")
.selectAll()
.where("goal_id", "=", goal.goalID)
.where("user_id", "=", userID)
.executeTakeFirst();
expect(subRow).toMatchObject({
goal_id: goal.goalID,
user_id: userID,
achieved: false,
});
});
it("writes a GOOD ADD_GOAL action row on success", async () => {
const chartId = await seedMinimalIidxSpChart();
const taker = { ip: "10.1.2.3", acct: { id: userID, username } };
await ACTION_AddGoal(taker, {
userID: userID,
game: "iidx",
playtype: "SP",
charts: { type: "single", data: chartId },
criteria: { key: "lamp", value: 7, mode: "single" },
});
const actionRow = await DB.selectFrom("action")
.selectAll()
.where("kind", "=", "ADD_GOAL")
.orderBy("ts_start", "desc")
.executeTakeFirstOrThrow();
expect(actionRow).toMatchObject({
result: "GOOD",
ip: "10.1.2.3",
user_id: userID,
});
});
it("throws 400 for an invalid criteria key", async () => {
const chartId = await seedMinimalIidxSpChart();
const taker = { ip: "127.0.0.1", acct: { id: userID, username } };
await expect(
ACTION_AddGoal(taker, {
userID: userID,
game: "iidx",
playtype: "SP",
charts: { type: "single", data: chartId },
criteria: { key: "not_a_real_metric", value: 0, mode: "single" },
}),
).rejects.toMatchObject({ code: 400 });
});
it("throws 400 when the chart does not exist", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userID, username } };
await expect(
ACTION_AddGoal(taker, {
userID: userID,
game: "iidx",
playtype: "SP",
charts: { type: "single", data: "00000000-0000-0000-0000-00000000dead" },
criteria: { key: "lamp", value: 7, mode: "single" },
}),
).rejects.toMatchObject({ code: 400 });
});
it("throws 409 when the user is already subscribed to that goal", async () => {
const chartId = await seedMinimalIidxSpChart();
const taker = { ip: "127.0.0.1", acct: { id: userID, username } };
const input = {
userID: userID,
game: "iidx" as const,
playtype: "SP" as const,
charts: { type: "single" as const, data: chartId },
criteria: { key: "lamp" as const, value: 7, mode: "single" as const },
};
await ACTION_AddGoal(taker, input);
await expect(ACTION_AddGoal(taker, input)).rejects.toMatchObject({ code: 409 });
});
});
+87
View File
@@ -0,0 +1,87 @@
import { MakeAction } from "#lib/actions/actions.js";
import { SubscribeFailReasons } from "#lib/constants/err-codes.js";
import { ServerConfig } from "#lib/setup/config.js";
import { ConstructGoal, SubscribeToGoal } from "#lib/targets/goals.js";
import DB from "#services/pg/db.js";
import { IsUserAdmin } from "#utils/user.js";
import { ExpectedErr } from "bliss";
import { sql } from "kysely";
import {
type GameGroup,
GamePTToV3,
GetGamePTConfig,
type MONGO_GoalDocument,
type Playtype,
} from "tachi-common";
export const ACTION_AddGoal = MakeAction("ADD_GOAL", async (taker, input) => {
const { userID, game: gameStr, playtype: playtypeStr, charts, criteria } = input;
const game = gameStr as GameGroup;
const playtype = playtypeStr as Playtype;
if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorised to modify this user's goals.");
}
const v3Game = GamePTToV3(game, playtype);
const row = await DB.selectFrom("goal_sub")
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
.select(sql<number>`count(*)::int`.as("c"))
.where("goal_sub.user_id", "=", userID)
.where("goal.game", "=", v3Game)
.executeTakeFirst();
const existingGoalsCount = Number(row?.c ?? 0);
if (existingGoalsCount > ServerConfig.MAX_GOAL_SUBSCRIPTIONS) {
throw new ExpectedErr(
400,
`You already have ${ServerConfig.MAX_GOAL_SUBSCRIPTIONS} goals. You cannot have anymore.`,
);
}
const gptConfig = GetGamePTConfig(game, playtype);
const validCriteria = [
...Object.keys(gptConfig.providedMetrics),
...Object.keys(gptConfig.derivedMetrics),
];
const criteriaKey = (criteria as { key?: string }).key;
if (typeof criteriaKey !== "string" || !validCriteria.includes(criteriaKey)) {
throw new ExpectedErr(
400,
`Invalid criteria '${String(criteriaKey)}', expected any of ${validCriteria.join(", ")}.`,
);
}
let goal: MONGO_GoalDocument;
try {
goal = await ConstructGoal(
charts as MONGO_GoalDocument["charts"],
criteria as MONGO_GoalDocument["criteria"],
game,
playtype,
);
} catch (e) {
throw new ExpectedErr(400, (e as Error).message);
}
const goalSub = await SubscribeToGoal(userID, goal, true);
if (goalSub === SubscribeFailReasons.ALREADY_SUBSCRIBED) {
throw new ExpectedErr(409, "You are already subscribed to this goal.");
}
if (goalSub === SubscribeFailReasons.ALREADY_ACHIEVED) {
throw new ExpectedErr(
400,
"You can't directly assign goals that you would immediately achieve.",
);
}
return { goalID: goal.goalID };
});
@@ -51,7 +51,6 @@ async function loadAllChartsForGame(game: GameGroup): Promise<Array<MONGO_ChartD
let q = DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", game)
.orderBy("chart.id", "asc")
.limit(FETCH_CHUNK);
@@ -67,7 +66,7 @@ async function loadAllChartsForGame(game: GameGroup): Promise<Array<MONGO_ChartD
}
for (const row of rows) {
out.push(ToChartDocument(row, row.song_legacy_id));
out.push(ToChartDocument(row));
}
lastChartId = rows[rows.length - 1]!.chart_id;
@@ -35,7 +35,7 @@ vi.mock("tachi-common", async (importOriginal) => {
});
describe("ACTION_BMSTableSync", () => {
const songPgId = "song-bms-action-test";
const songNewID = "song-bms-action-test";
const chartId = CreateChartID();
const legacySongId = 884_001;
let adminId: number;
@@ -55,7 +55,7 @@ describe("ACTION_BMSTableSync", () => {
afterEach(async () => {
await DB.deleteFrom("chart").where("id", "=", chartId).execute();
await DB.deleteFrom("song").where("id", "=", songPgId).execute();
await DB.deleteFrom("song").where("id", "=", songNewID).execute();
await DB.deleteFrom("action").where("user_id", "=", adminId).execute();
await DB.deleteFrom("account").where("id", "=", adminId).execute();
mockLoadBMSTable.mockReset();
@@ -64,7 +64,7 @@ describe("ACTION_BMSTableSync", () => {
it("applies mocked table levels to an existing BMS chart and song (DefaultAdminUser taker)", async () => {
await DB.insertInto("song")
.values({
id: songPgId,
id: songNewID,
legacy_id: legacySongId,
game_group: "bms",
title: "Unit Song",
@@ -86,7 +86,7 @@ describe("ACTION_BMSTableSync", () => {
id: chartId,
legacy_id: chartId,
game: "bms-7k",
song_id: songPgId,
song_id: songNewID,
level: "?",
level_num: 0,
is_primary: true,
@@ -121,7 +121,7 @@ describe("ACTION_BMSTableSync", () => {
const songRow = await DB.selectFrom("song")
.select("data")
.where("id", "=", songPgId)
.where("id", "=", songNewID)
.executeTakeFirstOrThrow();
const ts = (songRow.data as { tableString: string | null }).tableString;
@@ -1,7 +1,8 @@
import { LoadScoreDocumentById } from "#lib/db-formats/score.js";
import { mongoScoreDataToPg, pgScoreDataToMongo } from "#lib/v3/migration-tools";
import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { type MONGO_ScoreData, type MONGO_ScoreDocument } from "tachi-common";
import { type MONGO_ScoreData } from "tachi-common";
import { describe, expect, it } from "vitest";
import { ACTION_CustomiseScore } from "./customise-score";
@@ -109,19 +110,14 @@ describe("ACTION_CustomiseScore", () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
const result = await ACTION_CustomiseScore(taker, {
await ACTION_CustomiseScore(taker, {
scoreID: scoreId,
comment: "nice",
});
expect((result.score as MONGO_ScoreDocument).comment).toBe("nice");
const score = await LoadScoreDocumentById(scoreId);
const row = await DB.selectFrom("score")
.select("comment")
.where("id", "=", scoreId)
.executeTakeFirst();
expect(row?.comment).toBe("nice");
expect(score?.comment).toBe("nice");
});
it("updates highlight and propagates to pb when present", async () => {
@@ -1,5 +1,4 @@
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";
@@ -41,11 +40,5 @@ export const ACTION_CustomiseScore = MakeAction("CUSTOMISE_SCORE", async (_taker
}
});
const doc = await LoadScoreDocumentById(input.scoreID);
if (!doc) {
throw new ExpectedErr(500, "Score disappeared after update.");
}
return { score: doc };
return {};
});
@@ -62,7 +62,10 @@ describe("ACTION_DeleteImport", () => {
await ACTION_DeleteImport(taker, { id: importId });
const row = await DB.selectFrom("import").select("id").where("id", "=", importId).executeTakeFirst();
const row = await DB.selectFrom("import")
.select("id")
.where("id", "=", importId)
.executeTakeFirst();
expect(row).toBeUndefined();
});
@@ -73,7 +76,10 @@ describe("ACTION_DeleteImport", () => {
await ACTION_DeleteImport(taker, { id: importId });
const row = await DB.selectFrom("import").select("id").where("id", "=", importId).executeTakeFirst();
const row = await DB.selectFrom("import")
.select("id")
.where("id", "=", importId)
.executeTakeFirst();
expect(row).toBeUndefined();
});
@@ -11,10 +11,7 @@ export const ACTION_DeleteImport = MakeAction("DELETE_IMPORT", async (taker, { i
throw new ExpectedErr(404, "This import does not exist.");
}
if (
importDoc.userID !== taker.acct.id &&
!(await IsUserAdmin(taker.acct.id))
) {
if (importDoc.userID !== taker.acct.id && !(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorised to perform this action.");
}
@@ -1,8 +1,8 @@
import { mongoScoreDataToPg } from "#lib/v3/migration-tools";
import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { describe, expect, it } from "vitest";
import { type MONGO_ScoreData } from "tachi-common";
import { describe, expect, it } from "vitest";
import { ACTION_DeleteScore } from "./delete-score";
@@ -108,7 +108,10 @@ describe("ACTION_DeleteScore", () => {
await ACTION_DeleteScore(taker, { id: scoreId });
const row = await DB.selectFrom("score").select("id").where("id", "=", scoreId).executeTakeFirst();
const row = await DB.selectFrom("score")
.select("id")
.where("id", "=", scoreId)
.executeTakeFirst();
expect(row).toBeUndefined();
});
@@ -119,7 +122,10 @@ describe("ACTION_DeleteScore", () => {
await ACTION_DeleteScore(taker, { id: scoreId });
const row = await DB.selectFrom("score").select("id").where("id", "=", scoreId).executeTakeFirst();
const row = await DB.selectFrom("score")
.select("id")
.where("id", "=", scoreId)
.executeTakeFirst();
expect(row).toBeUndefined();
});
@@ -11,10 +11,7 @@ export const ACTION_DeleteScore = MakeAction("DELETE_SCORE", async (taker, { id,
throw new ExpectedErr(404, "This score does not exist.");
}
if (
score.userID !== taker.acct.id &&
!(await IsUserAdmin(taker.acct.id))
) {
if (score.userID !== taker.acct.id && !(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorised to perform this action.");
}
@@ -1,8 +1,8 @@
import { mongoScoreDataToPg } from "#lib/v3/migration-tools";
import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { describe, expect, it } from "vitest";
import { type MONGO_ScoreData } from "tachi-common";
import { describe, expect, it } from "vitest";
import { ACTION_DeleteSession } from "./delete-session";
@@ -124,8 +124,14 @@ describe("ACTION_DeleteSession", () => {
await ACTION_DeleteSession(taker, { id: sessionId });
const sess = await DB.selectFrom("session").select("id").where("id", "=", sessionId).executeTakeFirst();
const score = await DB.selectFrom("score").select("id").where("id", "=", scoreId).executeTakeFirst();
const sess = await DB.selectFrom("session")
.select("id")
.where("id", "=", sessionId)
.executeTakeFirst();
const score = await DB.selectFrom("score")
.select("id")
.where("id", "=", scoreId)
.executeTakeFirst();
expect(sess).toBeUndefined();
expect(score).toBeUndefined();
@@ -138,8 +144,14 @@ describe("ACTION_DeleteSession", () => {
await ACTION_DeleteSession(taker, { id: sessionId });
const sess = await DB.selectFrom("session").select("id").where("id", "=", sessionId).executeTakeFirst();
const score = await DB.selectFrom("score").select("id").where("id", "=", scoreId).executeTakeFirst();
const sess = await DB.selectFrom("session")
.select("id")
.where("id", "=", sessionId)
.executeTakeFirst();
const score = await DB.selectFrom("score")
.select("id")
.where("id", "=", scoreId)
.executeTakeFirst();
expect(sess).toBeUndefined();
expect(score).toBeUndefined();
@@ -12,10 +12,7 @@ export const ACTION_DeleteSession = MakeAction("DELETE_SESSION", async (taker, {
throw new ExpectedErr(404, "This session does not exist.");
}
if (
session.userID !== taker.acct.id &&
!(await IsUserAdmin(taker.acct.id))
) {
if (session.userID !== taker.acct.id && !(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorised to perform this action.");
}
@@ -0,0 +1,127 @@
import DB from "#services/pg/db";
import { seedMinimalIidxSpChart, seedUser } from "#test-utils/pg-fixtures";
import { beforeEach, describe, expect, it } from "vitest";
import { ACTION_AddGoal } from "./add-goal";
import { ACTION_RemoveGoalSubscription } from "./remove-goal-subscription";
describe("ACTION_RemoveGoalSubscription", () => {
let userID: number;
let username: string;
beforeEach(async () => {
({ id: userID, username } = await seedUser({ username: `goal_rm_${Date.now()}` }));
});
it("throws 403 when targeting another user as non-admin", async () => {
const other = await seedUser({ username: `goal_rm_other_${Date.now()}` });
const taker = { ip: "127.0.0.1", acct: { id: userID, username } };
await expect(
ACTION_RemoveGoalSubscription(taker, {
userID: other.id,
game: "iidx",
playtype: "SP",
goalID: "Gfake",
}),
).rejects.toMatchObject({ code: 403 });
});
it("throws 404 when the user is not subscribed to the goal", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userID, username } };
await expect(
ACTION_RemoveGoalSubscription(taker, {
userID: userID,
game: "iidx",
playtype: "SP",
goalID: "Gnonexistent_goal_id_xyz",
}),
).rejects.toMatchObject({ code: 404 });
});
it("deletes goal_sub when the user was subscribed", async () => {
const chartId = await seedMinimalIidxSpChart();
const taker = { ip: "127.0.0.1", acct: { id: userID, username } };
const { goalID } = await ACTION_AddGoal(taker, {
userID,
game: "iidx",
playtype: "SP",
charts: { type: "single", data: chartId },
criteria: { key: "lamp", value: 7, mode: "single" },
});
await ACTION_RemoveGoalSubscription(taker, {
userID,
game: "iidx",
playtype: "SP",
goalID,
});
const subRow = await DB.selectFrom("goal_sub")
.selectAll()
.where("goal_id", "=", goalID)
.where("user_id", "=", userID)
.executeTakeFirst();
expect(subRow).toBeUndefined();
});
it("writes a GOOD REMOVE_GOAL_SUBSCRIPTION action row on success", async () => {
const chartId = await seedMinimalIidxSpChart();
const taker = { ip: "10.4.5.6", acct: { id: userID, username } };
const { goalID } = await ACTION_AddGoal(taker, {
userID: userID,
game: "iidx",
playtype: "SP",
charts: { type: "single", data: chartId },
criteria: { key: "lamp", value: 7, mode: "single" },
});
await ACTION_RemoveGoalSubscription(taker, {
userID: userID,
game: "iidx",
playtype: "SP",
goalID,
});
const actionRow = await DB.selectFrom("action")
.selectAll()
.where("kind", "=", "REMOVE_GOAL_SUBSCRIPTION")
.orderBy("ts_start", "desc")
.executeTakeFirstOrThrow();
expect(actionRow).toMatchObject({
result: "GOOD",
ip: "10.4.5.6",
user_id: userID,
});
});
it("writes a BAD action row when removal fails with 404", async () => {
const taker = { ip: "10.7.8.9", acct: { id: userID, username } };
await expect(
ACTION_RemoveGoalSubscription(taker, {
userID: userID,
game: "iidx",
playtype: "SP",
goalID: "Gmissing_subscription",
}),
).rejects.toMatchObject({ code: 404 });
const actionRow = await DB.selectFrom("action")
.selectAll()
.where("kind", "=", "REMOVE_GOAL_SUBSCRIPTION")
.orderBy("ts_start", "desc")
.executeTakeFirstOrThrow();
expect(actionRow).toMatchObject({
result: "BAD",
ip: "10.7.8.9",
user_id: userID,
});
});
});
@@ -0,0 +1,67 @@
import { MakeAction } from "#lib/actions/actions.js";
import { ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents.js";
import { UnsubscribeFromGoal } from "#lib/targets/goals.js";
import DB from "#services/pg/db.js";
import { staticAssertUnreachable } from "#utils/misc.js";
import { IsUserAdmin } from "#utils/user.js";
import { ExpectedErr } from "bliss";
import type { Game } from "tachi-db";
import { GamePTToV3, type GameGroup, type Playtype } from "tachi-common";
export const ACTION_RemoveGoalSubscription = MakeAction(
"REMOVE_GOAL_SUBSCRIPTION",
async (taker, input) => {
const { userID, game: gameStr, playtype: playtypeStr, goalID } = input;
const game = gameStr as GameGroup;
const playtype = playtypeStr as Playtype;
if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorised to modify this user's goals.");
}
const v3Game = GamePTToV3(game, playtype);
const row = await DB.selectFrom("goal_sub")
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
.selectAll("goal_sub")
.select("goal.game as goal_game")
.where("goal_sub.user_id", "=", userID)
.where("goal_sub.goal_id", "=", goalID)
.where("goal.game", "=", v3Game)
.executeTakeFirst();
if (!row) {
throw new ExpectedErr(404, "You are not subscribed to this goal.");
}
const goalSub = ToGoalSubscriptionDocument({
...row,
goal_game: row.goal_game as Game,
});
const fail = await UnsubscribeFromGoal(goalSub, false);
if (!fail) {
return {};
}
switch (fail.reason) {
case "HAS_QUEST_DEPENDENCIES":
throw new ExpectedErr(
400,
`This goal is part of a quest you are subscribed to. It can only be removed by unsubscribing from the relevant quests: ${fail.parentQuests
.map((e) => `'${e.quest.name}'`)
.join(", ")}.`,
);
case "WAS_STANDALONE":
throw new ExpectedErr(
400,
"This goal was assigned by you and can't be removed as a consequence of another action.",
);
default:
staticAssertUnreachable(fail);
}
},
);
@@ -0,0 +1,138 @@
import { ServerConfig } from "#lib/setup/config";
import DB from "#services/pg/db";
import { seedUser } from "#test-utils/pg-fixtures";
import { beforeEach, describe, expect, it } from "vitest";
import { ACTION_SetRivals } from "./set-rivals";
describe("ACTION_SetRivals", () => {
let userId: number;
let username: string;
let rivalId: number;
beforeEach(async () => {
({ id: userId, username } = await seedUser({ username: `rival_set_${Date.now()}` }));
rivalId = (await seedUser({ username: `rival_target_${Date.now()}` })).id;
const ugptRow = {
game: "iidx-sp" as const,
pf_preferred_score_alg: null,
pf_preferred_session_alg: null,
pf_preferred_profile_alg: null,
pf_preferred_default_enum: null,
pf_default_table: null,
pf_preferred_ranking: null,
data: JSON.stringify({ display2DXTra: false, bpiTarget: 0 }),
};
await DB.insertInto("game_settings")
.values([
{ user_id: userId, ...ugptRow },
{ user_id: rivalId, ...ugptRow },
])
.execute();
});
it("replaces game_rival rows for the UGPT", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await ACTION_SetRivals(taker, {
userID: userId,
game: "iidx",
playtype: "SP",
rivalIDs: [rivalId],
});
const rows = await DB.selectFrom("game_rival")
.selectAll()
.where("user_id", "=", userId)
.where("game", "=", "iidx-sp")
.execute();
expect(rows).toHaveLength(1);
expect(rows[0]?.rival).toBe(rivalId);
});
it("writes a GOOD action row on success", async () => {
const taker = { ip: "10.0.0.3", acct: { id: userId, username } };
await ACTION_SetRivals(taker, {
userID: userId,
game: "iidx",
playtype: "SP",
rivalIDs: [rivalId],
});
const actionRow = await DB.selectFrom("action")
.selectAll()
.where("kind", "=", "SET_RIVALS")
.executeTakeFirstOrThrow();
expect(actionRow).toMatchObject({ result: "GOOD", ip: "10.0.0.3", user_id: userId });
});
it("throws 403 when targeting another user as non-admin", async () => {
const other = await seedUser({ username: `other_rival_${Date.now()}` });
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(
ACTION_SetRivals(taker, {
userID: other.id,
game: "iidx",
playtype: "SP",
rivalIDs: [rivalId],
}),
).rejects.toMatchObject({ code: 403 });
});
it("throws 400 when rivaling yourself", async () => {
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
await expect(
ACTION_SetRivals(taker, {
userID: userId,
game: "iidx",
playtype: "SP",
rivalIDs: [userId],
}),
).rejects.toMatchObject({ code: 400 });
});
it("throws 400 when more than MAX_RIVALS rivals are set", async () => {
const main = await seedUser({ username: `too_many_${Date.now()}` });
const rivalUsers = await Promise.all(
Array.from({ length: ServerConfig.MAX_RIVALS + 1 }, (_, i) =>
seedUser({ username: `too_many_r_${i}_${Date.now()}` }),
),
);
const ugptRow = {
game: "iidx-sp" as const,
pf_preferred_score_alg: null,
pf_preferred_session_alg: null,
pf_preferred_profile_alg: null,
pf_preferred_default_enum: null,
pf_default_table: null,
pf_preferred_ranking: null,
data: JSON.stringify({ display2DXTra: false, bpiTarget: 0 }),
};
await DB.insertInto("game_settings")
.values([
{ user_id: main.id, ...ugptRow },
...rivalUsers.map((u) => ({ user_id: u.id, ...ugptRow })),
])
.execute();
const taker = { ip: "127.0.0.1", acct: { id: main.id, username: main.username } };
await expect(
ACTION_SetRivals(taker, {
userID: main.id,
game: "iidx",
playtype: "SP",
rivalIDs: rivalUsers.map((u) => u.id),
}),
).rejects.toMatchObject({ code: 400 });
});
});
@@ -0,0 +1,44 @@
import { MakeAction } from "#lib/actions/actions.js";
import { SetRivalsFailReasons } from "#lib/constants/err-codes.js";
import { setRivalsWithResult } from "#lib/rivals/rivals.js";
import { staticAssertUnreachable } from "#utils/misc.js";
import { IsUserAdmin } from "#utils/user.js";
import { ExpectedErr } from "bliss";
import { FormatGameGroup, type GameGroup, type Playtype } from "tachi-common";
function throwIfSetRivalsFailed(
game: GameGroup,
playtype: Playtype,
reason: SetRivalsFailReasons,
): never {
switch (reason) {
case SetRivalsFailReasons.RIVALED_SELF:
throw new ExpectedErr(400, `You cannot rival yourself.`);
case SetRivalsFailReasons.RIVALS_HAVENT_PLAYED_GPT:
throw new ExpectedErr(
400,
`Not all of the rivals you specified have played ${FormatGameGroup(game, playtype)}.`,
);
case SetRivalsFailReasons.TOO_MANY:
throw new ExpectedErr(400, `You can't set more than 5 rivals.`);
default:
staticAssertUnreachable(reason);
}
}
export const ACTION_SetRivals = MakeAction("SET_RIVALS", async (taker, input) => {
const { userID, game: gameStr, playtype: playtypeStr, rivalIDs } = input;
const game = gameStr as GameGroup;
const playtype = playtypeStr as Playtype;
if (taker.acct.id !== userID && !(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorised to modify this user's rivals.");
}
const err = await setRivalsWithResult(userID, game, playtype, rivalIDs);
if (err !== null) {
throwIfSetRivalsFailed(game, playtype, err);
}
return {};
});
@@ -129,10 +129,8 @@ export async function updateBpiDataCore() {
if (updatedChartIDs.length !== 0) {
log.info(`Finished applying BPI changes in Postgres. Recalcing scores.`);
await RecalcAllScores({
game: "iidx",
chartID: { $in: updatedChartIDs },
});
// TODO(zk): We don't want to recalc _everything_ on changes like this?
await RecalcAllScores();
log.info(`Finished recalcing scores.`);
}
@@ -101,11 +101,8 @@ export async function updateDpTiersCore() {
if (updatedSongIDs.size !== 0) {
log.info(`${updatedSongIDs.size} songs were changed. Recalcing the relevant scores now.`);
await RecalcAllScores({
game: "iidx",
playtype: "DP",
songID: { $in: [...updatedSongIDs.values()] },
});
// TODO(zk): We don't want to recalc _everything_ on changes like this?
await RecalcAllScores();
log.info(`Recalced those scores.`);
}
@@ -190,10 +190,8 @@ export async function updateSp12DataCore() {
if (updatedChartIDs.length !== 0) {
log.info(`Finished applying SP12 changes. Recalcing.`);
await RecalcAllScores({
game: "iidx",
chartID: { $in: updatedChartIDs },
});
// TODO(zk): We don't want to recalc _everything_ on changes like this?
await RecalcAllScores();
log.info(`Finished recalcing scores.`);
}
+31 -3
View File
@@ -264,9 +264,7 @@ export const ActionSignatures = {
comment: z.union([z.string().min(1).max(120), z.null()]).optional(),
highlight: z.boolean().optional(),
}),
output: z.object({
score: z.unknown(),
}),
output: z.object({}),
},
UPSERT_KAI_AUTH_TOKEN: {
input: z.object({
@@ -315,6 +313,36 @@ export const ActionSignatures = {
}),
output: z.object({}),
},
SET_RIVALS: {
input: z.object({
userID: z.number().int(),
game: z.string(),
playtype: z.string(),
rivalIDs: z.array(z.number().int().positive()),
}),
output: z.object({}),
},
ADD_GOAL: {
input: z.object({
userID: z.number().int(),
game: z.string(),
playtype: z.string(),
charts: z.any(),
criteria: z.any(),
}),
output: z.object({
goalID: z.string(),
}),
},
REMOVE_GOAL_SUBSCRIPTION: {
input: z.object({
userID: z.number().int(),
game: z.string(),
playtype: z.string(),
goalID: z.string(),
}),
output: z.object({}),
},
UPDATE_UGPT_SHOWCASE: {
input: z.object({
userID: z.number().int(),
@@ -267,9 +267,7 @@ describe("GET /api/v1/games/:game/:playtype/activity (smoke)", () => {
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.body.recentSessions).toEqual(
expect.arrayContaining([
expect.objectContaining({ sessionID: `act-api-s-${base}` }),
]),
expect.arrayContaining([expect.objectContaining({ sessionID: `act-api-s-${base}` })]),
);
});
});
+8 -17
View File
@@ -60,12 +60,7 @@ function whereActivityUserId(
return (eb: ActivityWhereEb) => eb(column, "=", userID);
}
if (
userID &&
typeof userID === "object" &&
"$in" in userID &&
Array.isArray(userID.$in)
) {
if (userID && typeof userID === "object" && "$in" in userID && Array.isArray(userID.$in)) {
const ids = userID.$in;
if (ids.length === 0) {
@@ -204,17 +199,13 @@ export async function GetRecentActivity(
highlightQ = highlightQ.where(scoreUserWhere);
}
const [
classRows,
highlightRows,
{ goals, goalSubs },
{ quests, questSubs },
] = await Promise.all([
classQ.orderBy("class_achievement.timestamp", "desc").execute(),
highlightQ.orderBy(sql`score.time_achieved desc nulls last`).execute(),
GetRecentlyAchievedGoals({ ...query, timeAchieved: timeConstraint }, 0),
GetRecentlyAchievedQuests({ ...query, timeAchieved: timeConstraint }, 0),
]);
const [classRows, highlightRows, { goals, goalSubs }, { quests, questSubs }] =
await Promise.all([
classQ.orderBy("class_achievement.timestamp", "desc").execute(),
highlightQ.orderBy(sql`score.time_achieved desc nulls last`).execute(),
GetRecentlyAchievedGoals({ ...query, timeAchieved: timeConstraint }, 0),
GetRecentlyAchievedQuests({ ...query, timeAchieved: timeConstraint }, 0),
]);
const achievedClasses = classRows.map(ToClassAchievementDocument);
const recentlyHighlightedScores = highlightRows.map((row) =>
+16 -17
View File
@@ -23,16 +23,17 @@ export const SELECT_CHART = [
"chart.versions as chart_versions",
"chart.data as chart_data",
"chart.song_id as chart_song_id",
"song.legacy_id as song_legacy_id",
] as const;
type ChartRow = Selection<Database, "chart", (typeof SELECT_CHART)[number]>;
export type ChartRow = Selection<Database, "chart" | "song", (typeof SELECT_CHART)[number]>;
export function ToChartDocument(row: ChartRow, songLegacyId: number): MONGO_ChartDocument {
export function ToChartDocument(row: ChartRow): MONGO_ChartDocument {
const { playtype } = V3ToGamePT(row.chart_game);
return {
chartID: row.chart_id,
songID: songLegacyId,
songID: row.song_legacy_id,
level: row.chart_level,
levelNum: row.chart_level_num,
isPrimary: row.chart_is_primary,
@@ -44,30 +45,30 @@ export function ToChartDocument(row: ChartRow, songLegacyId: number): MONGO_Char
}
/**
* Fetches all charts for a given PG game string (e.g. "iidx-sp") and song PG UUID,
* Fetches all charts for a given game (e.g. "iidx-sp") and song id,
* including `chart.versions`. Returns fully-formed ChartDocuments using the provided
* legacy song ID for the `songID` field.
*/
export async function GetChartsBySongPgId(
export async function GetChartsBySongId(
v3Game: Game,
songPgId: string,
songLegacyId: number,
songID: string,
opts?: { omit2dxtraCharts?: boolean },
): Promise<MONGO_ChartDocument[]> {
let q = DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.where("song_id", "=", songPgId)
.where("song_id", "=", songID)
.where("game", "=", v3Game);
const gameGroup = V3ToGameGroup(v3Game);
if (opts?.omit2dxtraCharts && gameGroup === "iidx") {
q = q.where(sql<SqlBool>`(data->>'2dxtraSet') IS NULL`);
q = q.where(sql<SqlBool>`(chart.data->>'2dxtraSet') IS NULL`);
}
const rows = await q.execute();
return rows.map((row) => ToChartDocument(row, songLegacyId));
return rows.map((row) => ToChartDocument(row));
}
/**
@@ -80,7 +81,6 @@ export async function GetChartById(
const chartRow = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("chart.game", "=", v3Game)
.where("chart.id", "=", chartID)
.executeTakeFirst();
@@ -89,28 +89,27 @@ export async function GetChartById(
return undefined;
}
return ToChartDocument(chartRow, chartRow.song_legacy_id);
return ToChartDocument(chartRow);
}
// Loads charts from a list of IDs.
// This function should rarely be used - it's an antipattern in sql to do queries like this.
export async function GetChartsByIds(
game: GameGroup,
chartKeys: Array<string>,
chartIDs: Array<string>,
): Promise<Array<MONGO_ChartDocument>> {
if (chartKeys.length === 0) {
if (chartIDs.length === 0) {
return [];
}
const unique = [...new Set(chartKeys)];
const unique = [...new Set(chartIDs)];
const rows = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", game)
.where("chart.id", "in", unique)
.execute();
return rows.map((r) => ToChartDocument(r, r.song_legacy_id));
return rows.map(ToChartDocument);
}
@@ -19,7 +19,9 @@ type ClassAchievementRow = Selection<
(typeof SELECT_CLASS_ACHIEVEMENT_DOCUMENT)[number]
>;
export function ToClassAchievementDocument(row: ClassAchievementRow): MONGO_ClassAchievementDocument {
export function ToClassAchievementDocument(
row: ClassAchievementRow,
): MONGO_ClassAchievementDocument {
const { game, playtype } = V3ToGamePT(row.game as Game);
return {
@@ -267,6 +267,61 @@ export async function LoadPbDocumentsForUserPrimaryChartsSortedByAlg(
return Promise.all(rows.map((r) => ToPbScoreDocument(r as PbDocumentJoinRow)));
}
/**
* Top PBs in this GPT (`chart.game`), sorted by a numeric field in `calculated_data`
* (descending). Matches legacy Mongo `personal-bests` queries for the GPT PB leaderboard.
*/
export async function LoadPbDocumentsForGameSortedByCalculatedAlg(
v3Game: Game,
alg: string,
limit: number,
): Promise<MONGO_PBScoreDocument[]> {
const rows = await DB.selectFrom("pb")
.innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id")
.innerJoin("chart", "chart.id", "pb.chart_id")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_PB_DOCUMENT_WITH_LEADERBOARD)
.where("chart.game", "=", v3Game)
.where("pb.lens", "is", null)
.orderBy(sql`(pb.calculated_data::jsonb->>${sql.lit(alg)})::double precision`, "desc")
.orderBy("pb.time_achieved", "desc")
.limit(limit)
.execute();
return Promise.all(rows.map((r) => ToPbScoreDocument(r as PbDocumentJoinRow)));
}
/**
* PBs for any of the given users in this GPT (`chart.game`), sorted by a numeric field in
* `calculated_data` (descending). Matches legacy Mongo `personal-bests` queries that did not
* filter to primary charts only.
*/
export async function LoadPbDocumentsForUserSetSortedByCalculatedAlg(
userIds: number[],
v3Game: Game,
alg: string,
limit: number,
): Promise<MONGO_PBScoreDocument[]> {
if (userIds.length === 0) {
return [];
}
const rows = await DB.selectFrom("pb")
.innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id")
.innerJoin("chart", "chart.id", "pb.chart_id")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_PB_DOCUMENT_WITH_LEADERBOARD)
.where("pb.user_id", "in", userIds)
.where("chart.game", "=", v3Game)
.where("pb.lens", "is", null)
.orderBy(sql`(pb.calculated_data::jsonb->>${sql.lit(alg)})::double precision`, "desc")
.orderBy("pb.time_achieved", "desc")
.limit(limit)
.execute();
return Promise.all(rows.map((r) => ToPbScoreDocument(r as PbDocumentJoinRow)));
}
/** PBs for a set of users on one chart (Postgres `chart.id`). */
export async function LoadPbsByUserIdsAndChartPgId(
userIds: number[],
@@ -0,0 +1,29 @@
import type { MONGO_QuestlineDocument } from "tachi-common";
import { V3ToGamePT } from "tachi-common";
import { type Selection } from "kysely";
import { type Database } from "tachi-db";
export const SELECT_QUESTLINE = [
"questline.id as questline_id",
"questline.game as questline_game",
"questline.name as questline_name",
"questline.description as questline_description",
] as const;
export type QuestlineRow = Selection<Database, "questline", (typeof SELECT_QUESTLINE)[number]>;
export function ToQuestlineDocument(
row: QuestlineRow,
questIdsOrdered: Array<string>,
): MONGO_QuestlineDocument {
const { game, playtype } = V3ToGamePT(row.questline_game);
return {
questlineID: row.questline_id,
name: row.questline_name,
desc: row.questline_description,
game,
playtype,
quests: questIdsOrdered,
};
}
+2 -2
View File
@@ -23,7 +23,7 @@ export const SELECT_SONG_DOCUMENT = [
export async function GetSongByLegacyID(
game: GameGroup,
legacyId: number,
): Promise<{ doc: MONGO_SongDocument; pgId: string } | undefined> {
): Promise<{ doc: MONGO_SongDocument; newSongID: string } | undefined> {
const row = await DB.selectFrom("song")
.select(SELECT_SONG_DOCUMENT)
.where("legacy_id", "=", legacyId)
@@ -43,7 +43,7 @@ export async function GetSongByLegacyID(
data: row.song_data as SongDocumentData[typeof game],
};
return { doc, pgId: row.song_id };
return { doc, newSongID: row.song_id };
}
/**
+2 -2
View File
@@ -47,11 +47,11 @@ export async function GetFolderCharts(
const rows = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select([...SELECT_CHART, "song.legacy_id as song_legacy_id"])
.select(SELECT_CHART)
.where("chart.id", "in", chartIds)
.execute();
const charts = rows.map((row) => ToChartDocument(row, row.song_legacy_id));
const charts = rows.map(ToChartDocument);
return { charts };
}
@@ -118,13 +118,12 @@ export const CUSTOM_TACHI_IIDX_PLAYLISTS: Array<TachiIIDXPlaylist> = [
const rows = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("chart.game", "=", v3Game)
.where(sql<SqlBool>`(chart.data->>'kaidenAverage') IS NOT NULL`)
.where(sql<SqlBool>`(chart.data->>'2dxtraSet') IS NULL`)
.execute();
const charts = rows.map((r) => ToChartDocument(r, r.song_legacy_id)) as Array<
const charts = rows.map(ToChartDocument) as Array<
MONGO_ChartDocument<"iidx:DP" | "iidx:SP">
>;
@@ -3,7 +3,6 @@ import type https from "https";
import { log } from "#lib/log/log";
import { CloseScoreImportQueue } from "#lib/score-import/worker/queue";
import { monkDB } from "#services/mongo/db";
import { ClosePgConnection } from "#services/pg/db";
import { CloseRedisConnection } from "#services/redis/redis";
@@ -18,9 +17,6 @@ export function HandleSIGTERMGracefully(instance?: http.Server | https.Server) {
}
async function CloseEverythingElse() {
log.info({ shutdownInfo: true }, "Closing Mongo Database.");
await monkDB.close();
log.info({ shutdownInfo: true }, "Closing database...");
await ClosePgConnection();
@@ -5,10 +5,7 @@ import { DeorphanScores } from "#lib/score-import/framework/orphans/orphans";
import { WrapScriptPromise } from "#utils/misc";
export async function DeorphanScoresMain() {
const { success, failed, removed } = await DeorphanScores(
{},
log,
);
const { success, failed, removed } = await DeorphanScores({}, log);
log.info(`Finished attempting deorphaning.`);
@@ -55,7 +55,7 @@ export async function DeorphanBmsIfInOrphanChartPg(
log.info(`Song ${songDoc.title} was unorphaned forcefully (Postgres).`);
const songLegacyId = await GetNextBmsPmsSongLegacyId("bms");
const songPgId = CreateSongID();
const songNewID = CreateSongID();
songDoc.id = songLegacyId;
chartDoc.songID = songLegacyId;
@@ -66,7 +66,7 @@ export async function DeorphanBmsIfInOrphanChartPg(
await trx
.insertInto("song")
.values({
id: songPgId,
id: songNewID,
legacy_id: songLegacyId,
game_group: "bms",
title: songDoc.title,
@@ -84,7 +84,7 @@ export async function DeorphanBmsIfInOrphanChartPg(
id: chartDoc.chartID,
legacy_id: chartDoc.chartID,
game: v3Game,
song_id: songPgId,
song_id: songNewID,
level: chartDoc.level,
level_num: chartDoc.levelNum,
is_primary: chartDoc.isPrimary,
@@ -78,7 +78,7 @@ async function writeBmsPmsSongAndChart(
v3Game: Game,
songDoc: MONGO_SongDocument<"bms" | "pms">,
chartDoc: MONGO_ChartDocument,
songPgId: string,
songNewID: string,
songLegacyId: integer,
) {
const ftsDocument = [...songDoc.searchTerms, ...songDoc.altTitles].filter(Boolean).join(" ");
@@ -86,7 +86,7 @@ async function writeBmsPmsSongAndChart(
await trx
.insertInto("song")
.values({
id: songPgId,
id: songNewID,
legacy_id: songLegacyId,
game_group: gameGroup,
title: songDoc.title,
@@ -104,7 +104,7 @@ async function writeBmsPmsSongAndChart(
id: chartDoc.chartID,
legacy_id: chartDoc.chartID,
game: v3Game,
song_id: songPgId,
song_id: songNewID,
level: chartDoc.level,
level_num: chartDoc.levelNum,
is_primary: chartDoc.isPrimary,
@@ -198,7 +198,7 @@ export async function HandleOrphanQueue<GPT extends GPTString>(
songDocU.id = songLegacyId;
chartDocU.songID = songLegacyId;
const songPgId = CreateSongID();
const songNewID = CreateSongID();
await DB.transaction().execute(async (trx) => {
await writeBmsPmsSongAndChart(
@@ -207,7 +207,7 @@ export async function HandleOrphanQueue<GPT extends GPTString>(
v3Game,
songDocU as MONGO_SongDocument<"bms" | "pms">,
chartDocU,
songPgId,
songNewID,
songLegacyId,
);
@@ -272,7 +272,7 @@ export async function DeorphanIfInQueue<GPT extends GPTString>(
songDocU.id = songLegacyId;
chartDocU.songID = songLegacyId;
const songPgId = CreateSongID();
const songNewID = CreateSongID();
await DB.transaction().execute(async (trx) => {
await writeBmsPmsSongAndChart(
@@ -281,7 +281,7 @@ export async function DeorphanIfInQueue<GPT extends GPTString>(
v3Game,
songDocU as MONGO_SongDocument<"bms" | "pms">,
chartDocU,
songPgId,
songNewID,
songLegacyId,
);
+156 -92
View File
@@ -1,10 +1,10 @@
import type { BulkWriteUpdateOneOperation } from "mongodb";
import type { Game } from "tachi-db";
import { SetRivalsFailReasons } from "#lib/constants/err-codes";
import { log } from "#lib/log/log";
import { SendSetRivalNotification } from "#lib/notifications/notification-wrappers";
import { ServerConfig } from "#lib/setup/config";
import MONGODB_KILL from "#services/mongo/db";
import { pgScoreDataToMongo } from "#lib/v3/migration-tools";
import DB from "#services/pg/db";
import { ArrayDiff } from "#utils/misc";
import { GetUsersWithIDs, GetUserWithIDGuaranteed } from "#utils/user";
@@ -14,7 +14,7 @@ import {
GamePTToV3,
GetGamePTConfig,
type integer,
type MONGO_PBScoreDocument,
type PgScoreData,
type Playtype,
} from "tachi-common";
@@ -66,39 +66,52 @@ export async function GetEveryonesRivalIDs(
game: GameGroup,
playtype: Playtype,
): Promise<Record<number, Array<number>>> {
const allGameSettings = await MONGODB_KILL["game-settings"].find(
{
game,
playtype,
},
{
projection: {
userID: 1,
rivals: 1,
},
},
);
const v3Game = GamePTToV3(game, playtype) as Game;
const rows = await DB.selectFrom("game_rival")
.select(["user_id", "rival"])
.where("game", "=", v3Game)
.execute();
const lookupTable: Record<integer, Array<integer>> = {};
for (const d of allGameSettings) {
lookupTable[d.userID] = d.rivals;
for (const r of rows) {
const list = lookupTable[r.user_id] ?? [];
list.push(r.rival);
lookupTable[r.user_id] = list;
}
return lookupTable;
}
function metricValueFromPbRow(
v3Game: Game,
data: unknown,
derivedData: unknown,
metricKey: string,
): number | null {
const scoreData = pgScoreDataToMongo(v3Game, {
data,
derived: derivedData,
judgements: {},
} as PgScoreData<Game>);
const v = (scoreData as Record<string, unknown>)[metricKey];
return typeof v === "number" && !Number.isNaN(v) ? v : null;
}
/**
* Sets an array of userIDs to be this user's rivals. Performs validation on all of the
* rivals being players of the game, and not being duplicates. The maximum amount of rivals
* a player can have is ServerConfig.MAX_RIVALS (defaults to 5).
*
* @returns `null` on success, or a {@link SetRivalsFailReasons} code on validation failure.
*/
export async function SetRivals(
export async function setRivalsWithResult(
userID: integer,
game: GameGroup,
playtype: Playtype,
newRivals: Array<integer>,
) {
): Promise<SetRivalsFailReasons | null> {
if (newRivals.length > ServerConfig.MAX_RIVALS) {
return SetRivalsFailReasons.TOO_MANY;
}
@@ -107,22 +120,25 @@ export async function SetRivals(
return SetRivalsFailReasons.RIVALED_SELF;
}
const playedGPTCount = await MONGODB_KILL["game-settings"].count({
userID: { $in: newRivals },
game,
playtype,
});
const v3Game = GamePTToV3(game, playtype) as Game;
const { count } = await DB.selectFrom("game_settings")
.select(DB.fn.countAll().as("count"))
.where("game", "=", v3Game)
.where("user_id", "in", newRivals)
.executeTakeFirstOrThrow();
const playedGPTCount = Number(count);
// note: this check also checks that nothing provided is a duplicate.
if (playedGPTCount !== newRivals.length) {
return SetRivalsFailReasons.RIVALS_HAVENT_PLAYED_GPT;
}
const currentGameSettings = await MONGODB_KILL["game-settings"].findOne({
userID,
game,
playtype,
});
const currentGameSettings = await DB.selectFrom("game_settings")
.select("user_id")
.where("user_id", "=", userID)
.where("game", "=", v3Game)
.executeTakeFirst();
if (!currentGameSettings) {
log.error(
@@ -140,7 +156,8 @@ export async function SetRivals(
);
}
const newSubs = ArrayDiff(currentGameSettings.rivals, newRivals);
const currentRivalIDs = await GetRivalIDs(userID, game, playtype);
const newSubs = ArrayDiff(currentRivalIDs, newRivals);
const user = await GetUserWithIDGuaranteed(userID);
@@ -148,20 +165,39 @@ export async function SetRivals(
newSubs.map((toUserID) => SendSetRivalNotification(toUserID, user, game, playtype)),
);
await MONGODB_KILL["game-settings"].update(
{
userID,
game,
playtype,
},
{
$set: {
rivals: newRivals,
},
},
);
await DB.transaction().execute(async (trx) => {
await trx
.deleteFrom("game_rival")
.where("user_id", "=", userID)
.where("game", "=", v3Game)
.execute();
if (newRivals.length > 0) {
await trx
.insertInto("game_rival")
.values(
newRivals.map((rival) => ({
user_id: userID,
game: v3Game,
rival,
})),
)
.execute();
}
});
await UpdatePlayersRivalRankings(userID, game, playtype);
return null;
}
export function SetRivals(
userID: integer,
game: GameGroup,
playtype: Playtype,
newRivals: Array<integer>,
): Promise<SetRivalsFailReasons | null> {
return setRivalsWithResult(userID, game, playtype, newRivals);
}
/**
@@ -177,9 +213,7 @@ export async function AddRival(
rivalIDs.push(newRival);
// We use set rivals because its race condition safe. We don't have to check > 5
// or anything.
return SetRivals(userID, game, playtype, rivalIDs);
return setRivalsWithResult(userID, game, playtype, rivalIDs);
}
/**
@@ -202,27 +236,22 @@ export async function RemoveRival(
return null;
}
return SetRivals(userID, game, playtype, rivalIDs);
return setRivalsWithResult(userID, game, playtype, filteredRivals);
}
/**
* Get all of the userIDs of people who rival the userID for this GPT.
*/
export async function GetChallengerIDs(userID: integer, game: GameGroup, playtype: Playtype) {
const result = await MONGODB_KILL["game-settings"].find(
{
game,
playtype,
rivals: userID,
},
{
projection: {
userID: 1,
},
},
);
const v3Game = GamePTToV3(game, playtype) as Game;
return result.map((e) => e.userID);
const result = await DB.selectFrom("game_rival")
.select("user_id")
.where("game", "=", v3Game)
.where("rival", "=", userID)
.execute();
return result.map((e) => e.user_id);
}
/**
@@ -251,42 +280,77 @@ export async function UpdatePlayersRivalRankings(
playtype: Playtype,
) {
const gptConfig = GetGamePTConfig(game, playtype);
const metricKey = String(gptConfig.defaultMetric);
const rivalIDs = await GetRivalIDs(userID, game, playtype);
const v3Game = GamePTToV3(game, playtype) as Game;
// get all of this user's chartIDs so we know what to update
const userPBs = (await MONGODB_KILL["personal-bests"].find(
{ userID, game, playtype },
{ projection: { chartID: 1, [`scoreData.${gptConfig.defaultMetric}`]: 1 } },
)) as Array<{ chartID: string; scoreData: { percent: number } }>;
const userPBs = await DB.selectFrom("pb")
.innerJoin("chart", "chart.id", "pb.chart_id")
.select(["pb.row_id", "pb.chart_id", "pb.data", "pb.derived_data", "pb.calculated_data"])
.where("pb.user_id", "=", userID)
.where("chart.game", "=", v3Game)
.where("pb.lens", "is", null)
.execute();
const bwrite: Array<BulkWriteUpdateOneOperation<MONGO_PBScoreDocument>> = [];
await Promise.all(
userPBs.map(async (pb) => {
const rivalRank =
(await MONGODB_KILL["personal-bests"].count({
chartID: pb.chartID,
userID: { $in: rivalIDs },
[`scoreData.${gptConfig.defaultMetric}`]: { $gt: pb.scoreData.percent },
})) + 1;
bwrite.push({
updateOne: {
filter: { chartID: pb.chartID, userID },
update: {
$set: {
"rankingData.rivalRank": rivalRank,
},
},
},
});
}),
);
if (bwrite.length === 0) {
if (userPBs.length === 0) {
return;
}
await MONGODB_KILL["personal-bests"].bulkWrite(bwrite, { ordered: false });
const chartIds = [...new Set(userPBs.map((p) => p.chart_id))];
let rivalPBs: Array<{
chart_id: string;
data: unknown;
derived_data: unknown;
user_id: number;
}> = [];
if (rivalIDs.length > 0) {
rivalPBs = await DB.selectFrom("pb")
.select(["user_id", "chart_id", "data", "derived_data"])
.where("user_id", "in", rivalIDs)
.where("chart_id", "in", chartIds)
.where("lens", "is", null)
.execute();
}
await Promise.all(
userPBs.map(async (pb) => {
const userVal = metricValueFromPbRow(v3Game, pb.data, pb.derived_data, metricKey);
if (userVal === null) {
return;
}
let betterCount = 0;
for (const r of rivalPBs) {
if (r.chart_id !== pb.chart_id) {
continue;
}
const rivalVal = metricValueFromPbRow(v3Game, r.data, r.derived_data, metricKey);
if (rivalVal !== null && rivalVal > userVal) {
betterCount++;
}
}
const rivalRank = betterCount + 1;
const raw = pb.calculated_data;
const cd =
typeof raw === "string"
? (JSON.parse(raw) as Record<string, unknown>)
: ((raw ?? {}) as Record<string, unknown>);
delete cd.rank;
delete cd.outOf;
await DB.updateTable("pb")
.set({
calculated_data: JSON.stringify({
...cd,
rivalRank,
}),
})
.where("row_id", "=", pb.row_id)
.execute();
}),
);
}
@@ -6,9 +6,13 @@ import type {
MONGO_GoalSubscriptionDocument,
} from "tachi-common";
import { ToGoalDocument, ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents";
import { EvaluateGoalForUser, GetRelevantGoals } from "#lib/targets/goals";
import { EmitWebhookEvent } from "#lib/webhooks/webhooks";
import MONGODB_KILL from "#services/mongo/db";
import DB from "#services/pg/db";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import { sql } from "kysely";
import type { Game } from "tachi-db";
/**
* Update a user's progress on all of their set goals.
@@ -22,7 +26,6 @@ export async function GetAndUpdateUsersGoals(
const { goals, goalSubsMap } = await GetRelevantGoals(game, userID, chartIDs, log);
if (!goals.length) {
// if we hit the below code with an empty array mongodb will flip out on the bulkwrite op
return [];
}
@@ -66,7 +69,6 @@ export async function UpdateGoalsForUser(
);
const importInfo = [];
const bulkWrite = [];
const webhookEventContent = [];
for (const ret of returns) {
@@ -75,16 +77,21 @@ export async function UpdateGoalsForUser(
}
importInfo.push(ret.import);
bulkWrite.push(ret.bwrite);
if (ret.pgUpdate) {
await DB.updateTable("goal_sub")
.set(ret.pgUpdate.set)
.where("goal_sub.goal_id", "=", ret.pgUpdate.goalId)
.where("goal_sub.user_id", "=", ret.pgUpdate.userId)
.execute();
}
if (ret.webhookEvent) {
webhookEventContent.push(ret.webhookEvent);
}
}
if (bulkWrite.length === 0) {
// bulkwrite cannot be an empty array -- this means there's nothing to update or return, then.
// i.e. goals was non empty but returns was entirely [undefined, undefined...].
if (importInfo.length === 0) {
return [];
}
@@ -95,14 +102,12 @@ export async function UpdateGoalsForUser(
});
}
await MONGODB_KILL["goal-subs"].bulkWrite(bulkWrite, { ordered: false });
return importInfo;
}
/**
* Calls EvaluateGoalForUser, then processes the returns into a bulkWrite
* operation and an import statistic.
* Calls EvaluateGoalForUser, then processes the returns into a Postgres update
* and an import statistic.
* @returns undefined on error (i.e. EvaluateGoalForUser) OR if there's nothing
* to say (i.e. user didnt raise the goal).
*/
@@ -115,11 +120,9 @@ export async function ProcessGoal(
const res = await EvaluateGoalForUser(goal, userID, log);
if (!res) {
// some sort of error occured - its logged by the previous function.
return;
}
// nothing has changed
if (goalSub.progress === res.progress && goalSub.outOf === res.outOf) {
return;
}
@@ -142,8 +145,6 @@ export async function ProcessGoal(
let webhookEvent = null;
// if this is a newly-achieved goal
if (res.achieved && !goalSub.achieved) {
webhookEvent = {
goalID: goal.goalID,
@@ -153,32 +154,20 @@ export async function ProcessGoal(
};
}
let newTimeAchieved = null;
let newTimeAchievedMs: number | null = null;
if (newData.achieved) {
// if this goal was just achieved
if (goalSub.timeAchieved === null) {
newTimeAchieved = Date.now();
newTimeAchievedMs = Date.now();
} else {
// keep the old timestamp
newTimeAchieved = goalSub.timeAchieved;
newTimeAchievedMs = goalSub.timeAchieved;
}
}
// otherwise if this goal wasn't achieved then the timeAchieved is always null
const lastInteractionIso = UnixMillisecondsToISO8601(Date.now());
const setData = {
...newData,
timeAchieved: newTimeAchieved,
let wasInstantlyAchieved = goalSub.wasInstantlyAchieved;
// we're guaranteed that this works, because things
// that haven't changed return nothing instead of
// getting to this point.
lastInteraction: Date.now(),
} as unknown as Partial<MONGO_GoalSubscriptionDocument>;
// If this goal was achieved, and is now *not* achieved, we need to unset
// some things.
if (goalSub.achieved && !res.achieved) {
log.info(
{
@@ -189,21 +178,30 @@ export async function ProcessGoal(
`User ${userID} lost their achieved status on ${goal.name}.`,
);
// This goal can't be marked as instantly achieved, since it was lost.
setData.wasInstantlyAchieved = false;
wasInstantlyAchieved = false;
}
const bulkWrite = {
updateOne: {
filter: { goalID: goalSub.goalID, userID: goalSub.userID },
update: {
$set: setData,
},
},
const setPayload = {
progress: newData.progress,
progress_human: newData.progressHuman,
out_of: newData.outOf,
out_of_human: newData.outOfHuman,
achieved: newData.achieved,
time_achieved: newData.achieved
? newTimeAchievedMs !== null
? UnixMillisecondsToISO8601(newTimeAchievedMs)
: null
: null,
last_interaction: lastInteractionIso,
was_instantly_achieved: wasInstantlyAchieved,
};
return {
bwrite: bulkWrite,
pgUpdate: {
goalId: goalSub.goalID,
userId: goalSub.userID,
set: setPayload,
},
import: {
goalID: goal.goalID,
old: oldData,
@@ -214,42 +212,54 @@ export async function ProcessGoal(
}
export async function UpdateGoalsInFolder(folderID: string, log: KtLogger) {
const goals = await MONGODB_KILL.goals.find({
"charts.type": "folder",
"charts.data": folderID,
});
const goalRows = await DB.selectFrom("goal")
.selectAll()
.where(sql`goal.charts->>'type'`, "=", "folder")
.where(sql`goal.charts->>'data'`, "=", folderID)
.execute();
const goals = goalRows.map(ToGoalDocument);
log.info(`Updating ${goals.length} goals for ${folderID}`);
const goalSubs = await MONGODB_KILL["goal-subs"].find({
goalID: { $in: goals.map((e) => e.goalID) },
});
const goalIds = goals.map((g) => g.goalID);
log.info(`Updating ${goalSubs.length} goal subs for ${folderID}`);
if (goalIds.length === 0) {
return;
}
const subRows = await DB.selectFrom("goal_sub")
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
.selectAll("goal_sub")
.select("goal.game as goal_game")
.where("goal_sub.goal_id", "in", goalIds)
.execute();
log.info(`Updating ${subRows.length} goal subs for ${folderID}`);
const goalSubs = subRows.map((r) =>
ToGoalSubscriptionDocument({
...r,
goal_game: r.goal_game as Game,
}),
);
// (User -> (goalID -> GoalSub))
const ugsMap = new Map<integer, Map<string, MONGO_GoalSubscriptionDocument>>();
for (const gSub of goalSubs) {
const userID = gSub.userID;
const uid = gSub.userID;
if (ugsMap.has(userID)) {
ugsMap.get(userID)!.set(gSub.goalID, gSub);
if (ugsMap.has(uid)) {
ugsMap.get(uid)!.set(gSub.goalID, gSub);
} else {
ugsMap.set(userID, new Map());
ugsMap.get(userID)!.set(gSub.goalID, gSub);
ugsMap.set(uid, new Map());
ugsMap.get(uid)!.set(gSub.goalID, gSub);
}
}
const promises = [];
for (const [userID, goalSubsMap] of ugsMap.entries()) {
promises.push(async () => {
// hack: pass *all* goals here to avoid mass allocations
// use `skipMismatch` to ignore the cases where they don't match
await UpdateGoalsForUser(goals, goalSubsMap, userID, log, true);
});
}
await Promise.all(promises);
await Promise.all(
[...ugsMap.entries()].map(([uid, goalSubsMap]) =>
UpdateGoalsForUser(goals, goalSubsMap, uid, log, true),
),
);
}
@@ -324,7 +324,9 @@ describe("DeorphanScores beatoraja filter (Postgres)", () => {
.where("user_id", "=", userId)
.where("import_type", "=", "ir/beatoraja")
.where(sql<boolean>`(orphan_score.context::jsonb->'chart'->>'sha256') = ${sha}`)
.where(sql<boolean>`(orphan_score.data::jsonb->>'deviceType') IS DISTINCT FROM ${"BM_CONTROLLER"}`)
.where(
sql<boolean>`(orphan_score.data::jsonb->>'deviceType') IS DISTINCT FROM ${"BM_CONTROLLER"}`,
)
.execute();
expect(keyboardRows.map((r) => r.orphan_id)).toEqual(["obe-kb"]);
@@ -235,13 +235,19 @@ export async function DeorphanScores(filter: DeorphanScoresFilter, log: KtLogger
if (filter.chartSha256 !== undefined) {
q = q
.where("import_type", "=", "ir/beatoraja")
.where(sql<boolean>`(orphan_score.context::jsonb->'chart'->>'sha256') = ${filter.chartSha256}`);
.where(
sql<boolean>`(orphan_score.context::jsonb->'chart'->>'sha256') = ${filter.chartSha256}`,
);
if (filter.pmsPlaytype !== undefined) {
if (filter.pmsPlaytype === "Controller") {
q = q.where(sql<boolean>`(orphan_score.data::jsonb->>'deviceType') = ${"BM_CONTROLLER"}`);
q = q.where(
sql<boolean>`(orphan_score.data::jsonb->>'deviceType') = ${"BM_CONTROLLER"}`,
);
} else {
q = q.where(sql<boolean>`(orphan_score.data::jsonb->>'deviceType') IS DISTINCT FROM ${"BM_CONTROLLER"}`);
q = q.where(
sql<boolean>`(orphan_score.data::jsonb->>'deviceType') IS DISTINCT FROM ${"BM_CONTROLLER"}`,
);
}
}
}
@@ -1,5 +1,4 @@
import type { KtLogger } from "#lib/log/log";
import type { BulkWriteUpdateOneOperation } from "mongodb";
import type {
GameGroup,
GoalImportInfo,
@@ -9,10 +8,14 @@ import type {
Playtype,
QuestImportInfo,
} from "tachi-common";
import { GamePTToV3 } from "tachi-common";
import { EvaluateQuestProgress } from "#lib/targets/quests";
import { ToQuestDocument, ToQuestSubscriptionDocument } from "#lib/db-formats/target-documents";
import { EvaluateQuestProgress, GetGoalIDsFromQuest } from "#lib/targets/quests";
import { EmitWebhookEvent } from "#lib/webhooks/webhooks";
import MONGODB_KILL from "#services/mongo/db";
import DB from "#services/pg/db";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import type { Game } from "tachi-db";
export async function UpdateUsersQuests(
importGoalInfo: Array<GoalImportInfo>,
@@ -36,16 +39,12 @@ export async function UpdateQuestsForUser(
userID: integer,
log: KtLogger,
) {
// create a map here to avoid linear searching when
// co-iterating
const questSubMap = new Map<string, MONGO_QuestSubscriptionDocument>();
for (const um of questSubs) {
questSubMap.set(um.questID, um);
}
const bwrite: Array<BulkWriteUpdateOneOperation<MONGO_QuestSubscriptionDocument>> = [];
const importQuestInfo: Array<QuestImportInfo> = [];
await Promise.all(
@@ -62,18 +61,6 @@ export async function UpdateQuestsForUser(
return;
}
const bwriteOp: BulkWriteUpdateOneOperation<MONGO_QuestSubscriptionDocument> = {
updateOne: {
filter: { questID: quest.questID, userID },
update: {
$set: {
achieved,
progress,
},
},
},
};
const questInfo = {
questID: questSub.questID,
old: {
@@ -86,11 +73,19 @@ export async function UpdateQuestsForUser(
},
};
const setPayload: {
progress: number;
achieved: boolean;
last_interaction?: string;
time_achieved?: string | null;
} = {
progress,
achieved,
};
if (progress !== questSub.progress) {
importQuestInfo.push(questInfo);
// @ts-expect-error This property isn't read only, because I said so.
bwriteOp.updateOne.update.$set!.lastInteraction = Date.now();
setPayload.last_interaction = UnixMillisecondsToISO8601(Date.now());
}
if (achieved && !questSub.achieved) {
@@ -104,19 +99,17 @@ export async function UpdateQuestsForUser(
},
});
// make sure we mark the time achieved if this was just achieved.
// @ts-expect-error This property isn't read only, because I said so.
bwriteOp.updateOne.update.$set!.timeAchieved = Date.now();
setPayload.time_achieved = UnixMillisecondsToISO8601(Date.now());
}
bwrite.push(bwriteOp);
await DB.updateTable("quest_sub")
.set(setPayload)
.where("quest_sub.quest_id", "=", quest.questID)
.where("quest_sub.user_id", "=", userID)
.execute();
}),
);
if (bwrite.length !== 0) {
await MONGODB_KILL["quest-subs"].bulkWrite(bwrite, { ordered: false });
}
return importQuestInfo;
}
@@ -127,20 +120,42 @@ async function GetRelevantQuests(
userID: integer,
log: KtLogger,
) {
const questSubs = await MONGODB_KILL["quest-subs"].find({
game,
playtype: { $in: playtypes },
userID,
});
if (goalIDs.length === 0) {
return { quests: [] as Array<MONGO_QuestDocument>, questSubs: [] };
}
log.debug(`Found ${questSubs.length} quest-subs.`);
const v3Games = playtypes.map((pt) => GamePTToV3(game, pt));
const goalIdSet = new Set(goalIDs);
const quests = await MONGODB_KILL.quests.find({
questID: { $in: questSubs.map((e) => e.questID) },
"questData.goals.goalID": { $in: goalIDs },
});
const questSubRows = await DB.selectFrom("quest_sub")
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
.selectAll("quest_sub")
.select("quest.game as quest_game")
.where("quest_sub.user_id", "=", userID)
.where("quest.game", "in", v3Games)
.execute();
log.debug(`Found ${questSubRows.length} quest-subs.`);
const questSubs = questSubRows.map((r) =>
ToQuestSubscriptionDocument({
...r,
quest_game: r.quest_game as Game,
}),
);
const questIds = [...new Set(questSubRows.map((r) => r.quest_id))];
const questRows =
questIds.length === 0
? []
: await DB.selectFrom("quest").selectAll().where("id", "in", questIds).execute();
const quests = questRows
.map(ToQuestDocument)
.filter((q) => GetGoalIDsFromQuest(q).some((gid) => goalIdSet.has(gid)));
log.debug(`Found ${quests.length} relevant quests.`);
return { questSubs, quests };
return { quests, questSubs };
}
@@ -73,8 +73,8 @@ async function HandleOrphanChartProcess(
let chart;
let deorphanFilter:
| { chartSha256: string }
| { chartSha256: string; pmsPlaytype: Playtypes["pms"] };
| { chartSha256: string; pmsPlaytype: Playtypes["pms"] }
| { chartSha256: string };
if (game === "bms") {
deorphanFilter = { chartSha256: context.chart.sha256 };
+2 -2
View File
@@ -70,9 +70,9 @@ async function finalizeFolderSearchRows(
async function searchFoldersFtsAndTrgmCore(
search: string,
opts: {
allowedV3Games: Array<Game> | null;
limit: integer;
onlyActiveFolders: boolean;
allowedV3Games: Array<Game> | null;
},
): Promise<Array<{ __textScore: number } & MONGO_FolderDocument>> {
const q = search.trim();
@@ -237,8 +237,8 @@ async function searchFoldersFtsAndTrgmCore(
export async function SearchFoldersFtsAndTrgmGlobal(
search: string,
opts: {
limit?: integer;
games?: GameGroup[];
limit?: integer;
playtypes?: Playtype[];
} = {},
): Promise<Array<{ __textScore: number } & MONGO_FolderDocument>> {
+3 -135
View File
@@ -1,9 +1,7 @@
import type { FilterQuery } from "mongodb";
import type { ICollection } from "monk";
import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user";
import { TachiConfig } from "#lib/setup/config";
import MONGODB_KILL from "#services/mongo/db";
import DB from "#services/pg/db";
import { GetSongForIDGuaranteed } from "#utils/db";
import { EscapeForILIKE } from "#utils/misc";
@@ -29,136 +27,10 @@ import {
import { type Game } from "tachi-db";
import { SearchFoldersFtsAndTrgmGlobal } from "./folders.js";
import { AsyncFzf } from "./fzf/main";
import { type SearchSessionHit, SearchSessionsForUserGptFtsAndTrgm } from "./session-search.js";
export type { SearchSessionHit } from "./session-search.js";
import { SearchSpecificGameSongs, type SongSearchReturn } from "./songs.js";
interface SearchControls {
keys: Array<string>;
primary: string;
}
const SEARCH_CONTROLS = {
songs: {
keys: ["title", "artist", "searchTerms", "altTitles"],
primary: "id",
},
sessions: { keys: ["name"], primary: "sessionID" },
goals: { keys: ["name"], primary: "goalID" },
quests: { keys: ["name"], primary: "questID" },
users: { keys: ["username"], primary: "id" },
} satisfies Partial<Record<keyof typeof MONGODB_KILL, SearchControls>>;
interface SearchData {
primaryKey: number | string;
searchKey: string;
}
/**
* Perform a $text index search on a collection.
*
* This throws an error if the collection does not have a text index.
*
* @param existingMatch - An existing $match query to further filter results
* by.
*/
export async function SearchCollection<T extends object>(
collection: ICollection<T>,
search: string,
searchMethod: keyof typeof SEARCH_CONTROLS,
existingMatch: FilterQuery<T> = {},
limit = 500,
): Promise<Array<{ __textScore: number } & T>> {
const controls = SEARCH_CONTROLS[searchMethod];
const projection = Object.fromEntries([...controls.keys, controls.primary].map((e) => [e, 1]));
// we do the searching in-memory, because mongodb's search offerings are truly
// abysmal. I'm sorry. The performance is fine. I think.
const data = (await collection.find(existingMatch, { projection })) as Array<any>;
// instead of creating different AsyncFzf instances for each search control, we instead
// pool everything into the same instance. this ensures that all search controls are weighted
// equally, avoiding the case where bad results are included because all values for that search
// control are quite bad.
const searchData: Array<SearchData> = [];
for (const key of controls.keys) {
searchData.push(
...data
.filter((d) => d[key])
.flatMap<SearchData>((d) => {
// handles stuff like searchTerms or altTitles being an array
if (Array.isArray(d[key])) {
return d[key].map((k: any) => ({
primaryKey: d[controls.primary],
searchKey: k.toString(),
}));
}
return [
{
primaryKey: d[controls.primary],
searchKey: d[key].toString(),
},
];
}),
);
}
// we don't use fzf's limit since our best results may include duplicates,
// e.g. "gravekeeper" might match both the search terms "Gravekeeper" and
// "Gravekeeper of the Dead Tree" from the same song. The library doesn't do
// anything special with the option anyways; it still runs through all results
// but simply remove excess items before returning it to us.
const fzf = new AsyncFzf(searchData, {
selector: (item) => item.searchKey,
sort: true,
casing: "case-insensitive",
});
let results = await fzf.find(search);
// filter out anything far from the best match
// 30 was chosen arbitrarily :)
const max = results[0]?.score ?? 0;
results = results.filter((r) => r.score >= max - 30);
const pkeys: Set<number | string> = new Set();
const scores: Record<string, number> = {};
for (const res of results) {
const pkey = res.item.primaryKey;
const existingScore = scores[pkey];
pkeys.add(pkey);
if (!existingScore || res.score > existingScore) {
scores[pkey] = res.score;
}
if (pkeys.size > limit) {
break;
}
}
const documents: Array<any> = await (collection as ICollection).find({
[controls.primary]: { $in: Array.from(pkeys) },
});
// however, the results we get back from MongoDB are unordered, so we have to sort again.
documents.sort(
(a, b) => (scores[b[controls.primary]] ?? 0) - (scores[a[controls.primary]] ?? 0),
);
for (const doc of documents) {
doc.__textScore = scores[doc[controls.primary]] ?? 0;
}
return documents;
}
export type { SearchSessionHit } from "./session-search.js";
export function SearchSessions(
search: string,
@@ -227,14 +99,10 @@ async function SearchAllGamesSingleGame(game: GameGroup, search: string) {
/**
* Searches all games' songs.
*/
export async function SearchAllGamesSongs(search: string) {
return SearchGamesSongs(search, TachiConfig.GAMES);
}
export async function SearchGamesSongs(search: string, games: Array<GameGroup>) {
export async function SearchGamesSongs(search: string, games: Array<GameGroup> | null) {
const promises = [];
for (const game of games) {
for (const game of games ?? TachiConfig.GAMES) {
promises.push(SearchAllGamesSingleGame(game, search));
}
@@ -1,4 +1,4 @@
import { GetChartsBySongPgId } from "#lib/db-formats/chart";
import { GetChartsBySongId } from "#lib/db-formats/chart";
import DB from "#services/pg/db";
import { sql } from "kysely";
import {
@@ -31,13 +31,13 @@ export async function SearchSpecificGameSongsAndCharts(
const chartLists = await Promise.all(
songs.map((song) => {
const pgId = pgIdByLegacyId.get(song.id);
const songID = pgIdByLegacyId.get(song.id);
if (!pgId) {
if (!songID) {
return Promise.resolve([] as Array<MONGO_ChartDocument>);
}
return GetChartsBySongPgId(v3Game, pgId, song.id, {
return GetChartsBySongId(v3Game, songID, {
omit2dxtraCharts: game === "iidx",
});
}),
@@ -60,7 +60,11 @@ export async function SearchGlobalGameSongsAndCharts(
playtype?: Playtype,
limit = MAX_SONG_SEARCH_RESULTS_PER_GAME,
): Promise<Array<{ chart: MONGO_ChartDocument; playcount: integer; song: MONGO_SongDocument }>> {
const { songs, pgIdByLegacyId } = await searchSpecificGameSongsWithPgIds(game, search, limit);
const { songs, pgIdByLegacyId: songIdByLegacyId } = await searchSpecificGameSongsWithPgIds(
game,
search,
limit,
);
if (!playtype) {
throw new Error("SearchGlobalGameSongsAndCharts requires playtype");
@@ -79,14 +83,14 @@ export async function SearchGlobalGameSongsAndCharts(
break;
}
const pgId = pgIdByLegacyId.get(song.id);
const songID = songIdByLegacyId.get(song.id);
if (!pgId) {
if (!songID) {
continue;
}
// eslint-disable-next-line no-await-in-loop -- stop after enough charts; avoids loading every song's charts when the cap is already reached.
const songCharts = await GetChartsBySongPgId(v3Game, pgId, song.id, {
const songCharts = await GetChartsBySongId(v3Game, songID, {
omit2dxtraCharts: game === "iidx",
});
@@ -1,4 +1,4 @@
import { GetChartsBySongPgId } from "#lib/db-formats/chart";
import { GetChartsBySongId } from "#lib/db-formats/chart";
import { SearchGlobalGameSongsAndCharts } from "#lib/search/song-charts.js";
import DB from "#services/pg/db";
import { importSeedsSubset } from "#services/pg/seeds";
@@ -523,7 +523,7 @@ describe("IIDX 2dxtraSet exclusion from search", () => {
expect(res.some((r) => r.legacy_id === 9_000_861)).toBe(true);
});
it("GetChartsBySongPgId omits 2dxtra charts when omit2dxtraCharts is true", async () => {
it("GetChartsBysongNewID omits 2dxtra charts when omit2dxtraCharts is true", async () => {
const sid = makeSongId(870);
await DB.insertInto("song")
@@ -569,8 +569,8 @@ describe("IIDX 2dxtraSet exclusion from search", () => {
])
.execute();
const all = await GetChartsBySongPgId(GamePTToV3("iidx", "SP"), sid, 9_000_870);
const filtered = await GetChartsBySongPgId(GamePTToV3("iidx", "SP"), sid, 9_000_870, {
const all = await GetChartsBySongId(GamePTToV3("iidx", "SP"), sid);
const filtered = await GetChartsBySongId(GamePTToV3("iidx", "SP"), sid, {
omit2dxtraCharts: true,
});
@@ -0,0 +1,67 @@
import {
type PbDocumentJoinRow,
SELECT_PB_DOCUMENT_WITH_LEADERBOARD,
ToPbScoreDocument,
} from "#lib/db-formats/pb.js";
import DB from "#services/pg/db.js";
import {
type GetScoreMetricConf,
type MONGO_GoalDocument,
type MONGO_PBScoreDocument,
} from "tachi-common";
type GoalMetricConf = NonNullable<ReturnType<typeof GetScoreMetricConf>>;
/**
* Loads the user's leaderboard PB row for each chart in `chartPgIds` (Postgres `chart.id`).
*/
export async function LoadPbsForUserOnChartsForGoal(
userId: number,
chartIDs: string[],
): Promise<MONGO_PBScoreDocument[]> {
if (chartIDs.length === 0) {
return [];
}
const rows = await DB.selectFrom("pb")
.innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id")
.innerJoin("chart", "chart.id", "pb.chart_id")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_PB_DOCUMENT_WITH_LEADERBOARD)
.where("pb.user_id", "=", userId)
.where("chart.id", "in", chartIDs)
.execute();
return Promise.all(rows.map((r) => ToPbScoreDocument(r as PbDocumentJoinRow)));
}
export function getGoalMetricValueFromPb(
pb: MONGO_PBScoreDocument,
criteriaKey: MONGO_GoalDocument["criteria"]["key"],
scoreConf: GoalMetricConf,
): number | null {
if (scoreConf.type === "ENUM") {
const v = pb.scoreData.enumIndexes[criteriaKey as keyof typeof pb.scoreData.enumIndexes];
return typeof v === "number" ? v : null;
}
const v = (pb.scoreData as Record<string, unknown>)[criteriaKey as string];
return typeof v === "number" ? v : null;
}
export function pbMeetsGoalThreshold(
pb: MONGO_PBScoreDocument,
criteriaKey: MONGO_GoalDocument["criteria"]["key"],
threshold: number,
scoreConf: GoalMetricConf,
): boolean {
const v = getGoalMetricValueFromPb(pb, criteriaKey, scoreConf);
if (v === null) {
return false;
}
return v >= threshold;
}
+18 -24
View File
@@ -1,13 +1,15 @@
import type { GoalCriteriaFormatter } from "#game-implementations/types";
import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations";
import { GetChartById } from "#lib/db-formats/chart";
import { LoadFolderDocumentById } from "#lib/db-formats/folders";
import { GetFolderChartIDs } from "#lib/folders/folders.js";
import MONGODB_KILL from "#services/mongo/db";
import { GetFolderForIDGuaranteed, HumaniseChartID } from "#utils/db";
import { HumaniseChartID } from "#utils/db";
import { HumanisedJoinArray, OnlyFloatToDP } from "#utils/misc";
import {
FormatGameGroup,
type GameGroup,
GamePTToV3,
GetGPTConfig,
GetGPTString,
GetScoreMetricConf,
@@ -118,7 +120,11 @@ async function FormatCharts(
}
case "folder": {
const folder = await GetFolderForIDGuaranteed(charts.data);
const folder = await LoadFolderDocumentById(charts.data);
if (!folder) {
throw new Error(`Folder ${charts.data} not found.`);
}
return `the ${folder.title} folder`;
}
@@ -182,14 +188,13 @@ export async function ValidateGoalChartsAndCriteria(
) {
let chartCount = 0;
const v3Game = GamePTToV3(game, playtype);
// Validating the charts supplied
switch (charts.type) {
case "single": {
const chart = await MONGODB_KILL.anyCharts[game].findOne({
playtype,
chartID: charts.data,
});
const chart = await GetChartById(v3Game, charts.data);
if (!chart) {
throw new Error(
@@ -202,13 +207,9 @@ export async function ValidateGoalChartsAndCriteria(
}
case "folder": {
const folder = await MONGODB_KILL.folders.findOne({
game,
playtype,
folderID: charts.data,
});
const folder = await LoadFolderDocumentById(charts.data);
if (!folder) {
if (!folder || folder.game !== game || folder.playtype !== playtype) {
throw new Error(
`A folder with id ${charts.data} does not exist for ${game}:${playtype}.`,
);
@@ -225,14 +226,11 @@ export async function ValidateGoalChartsAndCriteria(
);
}
const multiCharts = await MONGODB_KILL.anyCharts[game].find({
playtype,
chartID: { $in: charts.data },
});
const multiCharts = await Promise.all(charts.data.map((chartID) => GetChartById(v3Game, chartID)));
if (multiCharts.length !== charts.data.length) {
if (multiCharts.some((c) => c === undefined)) {
throw new Error(
`Expected charts.data to match ${charts.data.length} charts. Instead, it only matched ${multiCharts.length}. Are all of these chartIDs valid?`,
`Expected charts.data to match ${charts.data.length} charts. Instead, it only matched ${multiCharts.filter(Boolean).length}. Are all of these chartIDs valid?`,
);
}
@@ -297,11 +295,7 @@ export async function ValidateGoalChartsAndCriteria(
let err;
if (config.chartDependentMax) {
const chart = await MONGODB_KILL.anyCharts[game].findOne({
playtype,
// guaranteed by previous if statement
chartID: charts.data as string,
});
const chart = await GetChartById(v3Game, charts.data as string);
if (!chart) {
throw new Error(
+279 -215
View File
@@ -1,15 +1,29 @@
import type { GoalCriteriaFormatter } from "#game-implementations/types";
import type { FilterQuery } from "mongodb";
import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations";
import { SubscribeFailReasons } from "#lib/constants/err-codes";
import {
ToGoalDocument,
ToGoalSubscriptionDocument,
ToQuestDocument,
ToQuestSubscriptionDocument,
} from "#lib/db-formats/target-documents";
import { GetFolderChartIDs } from "#lib/folders/folders.js";
import {
getGoalMetricValueFromPb,
LoadPbsForUserOnChartsForGoal,
pbMeetsGoalThreshold,
} from "#lib/targets/goal-pb-queries.js";
import { type KtLogger, log } from "#lib/log/log";
import MONGODB_KILL from "#services/mongo/db";
import DB from "#services/pg/db";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import fjsh from "fast-json-stable-hash";
import { sql } from "kysely";
import type { Game } from "tachi-db";
import {
FormatGameGroup,
type GameGroup,
GamePTToV3,
GetGPTConfig,
GetGPTString,
GetScoreMetricConf,
@@ -21,6 +35,8 @@ import {
type MONGO_QuestDocument,
type MONGO_QuestSubscriptionDocument,
type Playtype,
V3ToGameGroup,
v3AllGames,
} from "tachi-common";
import { CreateGoalTitle as CreateGoalName, ValidateGoalChartsAndCriteria } from "./goal-utils";
@@ -58,8 +74,6 @@ export async function EvaluateGoalForUser(
userID: integer,
log: KtLogger,
): Promise<EvaluatedGoalReturn | null> {
// First, we need to resolve the set of charts this
// goal involves.
const chartIDs = await ResolveGoalCharts(goal);
const gptString = GetGPTString(goal.game, goal.playtype);
const gptConfig = GetGPTConfig(gptString);
@@ -71,41 +85,27 @@ export async function EvaluateGoalForUser(
);
}
let scoreDataKey;
if (scoreConf.type === "ENUM") {
scoreDataKey = `scoreData.enumIndexes.${goal.criteria.key}`;
} else {
scoreDataKey = `scoreData.${goal.criteria.key}`;
}
// lets configure a "base" query for our requests.
const scoreQuery: FilterQuery<MONGO_PBScoreDocument> = {
userID,
game: goal.game,
playtype: goal.playtype,
// normally, this would be a VERY WORRYING line of code, but goal.criteria.key is guaranteed to be
// within a specific set of fields.
[scoreDataKey]: { $gte: goal.criteria.value },
chartID: { $in: chartIDs },
};
const pbs = await LoadPbsForUserOnChartsForGoal(userID, chartIDs);
switch (goal.criteria.mode) {
case "single": {
const res = await MONGODB_KILL["personal-bests"].findOne(scoreQuery);
const outOfHuman = HumaniseGoalOutOf(gptString, goal.criteria.key, goal.criteria.value);
if (res) {
const qualifying = pbs.filter((pb) =>
pbMeetsGoalThreshold(pb, goal.criteria.key, goal.criteria.value, scoreConf),
);
if (qualifying.length > 0) {
const res = qualifying[0]!;
return {
achieved: true,
outOf: goal.criteria.value,
progress:
scoreConf.type === "ENUM"
? // @ts-expect-error this is always correct but the typesystem is rightfully concerned
? // @ts-expect-error narrow
res.scoreData.enumIndexes[goal.criteria.key]
: // @ts-expect-error see above
: // @ts-expect-error narrow
res.scoreData[goal.criteria.key],
outOfHuman,
progressHuman: HumaniseGoalProgress(
@@ -117,21 +117,15 @@ export async function EvaluateGoalForUser(
};
}
// if we didn't find a PB that achieved the goal immediately
// fetch the next best thing.
const nextBestQuery: FilterQuery<MONGO_PBScoreDocument> = {
userID,
game: goal.game,
playtype: goal.playtype,
chartID: { $in: chartIDs },
};
const scored = pbs
.map((pb) => ({
pb,
v: getGoalMetricValueFromPb(pb, goal.criteria.key, scoreConf),
}))
.filter((e): e is { pb: MONGO_PBScoreDocument; v: number } => e.v !== null)
.sort((a, b) => b.v - a.v);
const nextBestScore = await MONGODB_KILL["personal-bests"].findOne(nextBestQuery, {
sort: { [scoreDataKey]: -1 },
});
// user has no scores on any charts in this set.
if (!nextBestScore) {
if (scored.length === 0) {
return {
achieved: false,
outOf: goal.criteria.value,
@@ -141,15 +135,17 @@ export async function EvaluateGoalForUser(
};
}
const nextBestScore = scored[0]!.pb;
return {
achieved: false,
outOf: goal.criteria.value,
outOfHuman,
progress:
scoreConf.type === "ENUM"
? // @ts-expect-error this is always correct but the typesystem is rightfully concerned
? // @ts-expect-error narrow
nextBestScore.scoreData.enumIndexes[goal.criteria.key]
: // @ts-expect-error see above
: // @ts-expect-error narrow
nextBestScore.scoreData[goal.criteria.key],
progressHuman: HumaniseGoalProgress(
gptString,
@@ -162,21 +158,19 @@ export async function EvaluateGoalForUser(
case "absolute":
case "proportion": {
let count;
let count: number;
// abs -> Absolute mode, such as clear 10 charts.
if (goal.criteria.mode === "absolute") {
count = goal.criteria.countNum;
} else {
// proportion -> Proportional mode, the value
// is a multiplier for the amount of charts
// available -- i.e. 0.1 * charts.
const totalChartCount = chartIDs.length;
count = Math.floor(goal.criteria.countNum * totalChartCount);
}
const userCount = await MONGODB_KILL["personal-bests"].count(scoreQuery);
const userCount = pbs.filter((pb) =>
pbMeetsGoalThreshold(pb, goal.criteria.key, goal.criteria.value, scoreConf),
).length;
return {
achieved: userCount >= count,
@@ -188,8 +182,6 @@ export async function EvaluateGoalForUser(
}
default: {
// note that this seemingly nonsensical type assertion is because typescript has whittled down
// goal.criteria (correctly) to 'never', but we want to log if something somehow ends up here (it shouldn't).
log.warn(
{ goal },
`Invalid goal: ${goal.goalID}, unknown criteria.mode ${
@@ -305,11 +297,9 @@ export async function ConstructGoal(
game: GameGroup,
playtype: Playtype,
): Promise<MONGO_GoalDocument> {
// Throws if the charts or criteria are invalid somehow.
await ValidateGoalChartsAndCriteria(charts, criteria, game, playtype);
// @ts-expect-error It's complaining because the potential criteria types might mismatch.
// they're right, but this is enforced by ValidateGoalChartsAndCriteria.
const MONGO_GoalDocument: MONGO_GoalDocument = {
game,
playtype,
@@ -322,6 +312,26 @@ export async function ConstructGoal(
return MONGO_GoalDocument;
}
async function ensureGoalRow(doc: MONGO_GoalDocument) {
const v3Game = GamePTToV3(doc.game, doc.playtype);
const existed = await DB.selectFrom("goal").select("goal.id").where("id", "=", doc.goalID).executeTakeFirst();
if (!existed) {
await DB.insertInto("goal")
.values({
id: doc.goalID,
game: v3Game,
name: doc.name,
charts: doc.charts,
criteria: doc.criteria,
})
.execute();
log.info(`Inserting new goal '${doc.name}'.`);
}
}
/**
* Subscribes a user to the provided goal document. Handles deduping goals naturally
* and general good stuff.
@@ -338,47 +348,48 @@ export async function SubscribeToGoal(
MONGO_GoalDocument: MONGO_GoalDocument,
isStandaloneAssignment: boolean,
) {
const goalExists = await MONGODB_KILL.goals.findOne({ goalID: MONGO_GoalDocument.goalID });
await ensureGoalRow(MONGO_GoalDocument);
if (!goalExists) {
await MONGODB_KILL.goals.insert(MONGO_GoalDocument);
log.info(`Inserting new goal '${MONGO_GoalDocument.name}'.`);
}
const existingSub = await DB.selectFrom("goal_sub")
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
.selectAll("goal_sub")
.select("goal.game as goal_game")
.where("goal_sub.goal_id", "=", MONGO_GoalDocument.goalID)
.where("goal_sub.user_id", "=", userID)
.executeTakeFirst();
const userAlreadySubscribed = await MONGODB_KILL["goal-subs"].findOne({
userID,
goalID: MONGO_GoalDocument.goalID,
});
if (existingSub) {
const userAlreadySubscribed = ToGoalSubscriptionDocument({
...existingSub,
goal_game: existingSub.goal_game as Game,
});
if (userAlreadySubscribed) {
// A quest trying to assign an already subscribed goal should know that.
// (not that it cares)
if (!isStandaloneAssignment) {
return SubscribeFailReasons.ALREADY_SUBSCRIBED;
}
// if the user was already standalone-subscribed, ignore another standalone
// assignment.
if (userAlreadySubscribed.wasAssignedStandalone) {
return SubscribeFailReasons.ALREADY_SUBSCRIBED;
}
// otherwise, this is a standalone assignment to a goal that was already assigned
// as a consequence of a quest. Mark it as standalone
await MONGODB_KILL["goal-subs"].update(
{
userID,
goalID: MONGO_GoalDocument.goalID,
},
{
$set: {
wasAssignedStandalone: true,
},
},
);
await DB.updateTable("goal_sub")
.set({ was_assigned_standalone: true })
.where("goal_id", "=", MONGO_GoalDocument.goalID)
.where("user_id", "=", userID)
.execute();
// return this goal sub document, it's fast!
return { ...userAlreadySubscribed, wasAssignedStandalone: true };
const updated = await DB.selectFrom("goal_sub")
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
.selectAll("goal_sub")
.select("goal.game as goal_game")
.where("goal_sub.goal_id", "=", MONGO_GoalDocument.goalID)
.where("goal_sub.user_id", "=", userID)
.executeTakeFirstOrThrow();
return ToGoalSubscriptionDocument({
...updated,
goal_game: updated.goal_game as Game,
});
}
const result = await EvaluateGoalForUser(MONGO_GoalDocument, userID, log);
@@ -387,39 +398,72 @@ export async function SubscribeToGoal(
throw new Error(`Couldn't evaluate goal? See previous logs.`);
}
// standalone assignments shouldn't be allowed to assign instantly-achieved
// goals
if (result.achieved && isStandaloneAssignment) {
return SubscribeFailReasons.ALREADY_ACHIEVED;
}
// @ts-expect-error TS can't resolve this.
// because it can't explode out the types.
const goalSub: MONGO_GoalSubscriptionDocument = {
const nowMs = Date.now();
const nowIso = UnixMillisecondsToISO8601(nowMs);
const baseSub = {
outOf: result.outOf,
outOfHuman: result.outOfHuman,
progress: result.progress,
progressHuman: result.progressHuman,
userID,
lastInteraction: null,
timeAchieved: result.achieved ? Date.now() : null,
game: MONGO_GoalDocument.game,
playtype: MONGO_GoalDocument.playtype,
goalID: MONGO_GoalDocument.goalID,
achieved: result.achieved,
wasInstantlyAchieved: result.achieved,
wasAssignedStandalone: isStandaloneAssignment,
};
await MONGODB_KILL["goal-subs"].insert(goalSub);
const goalSub: MONGO_GoalSubscriptionDocument = result.achieved
? {
...baseSub,
achieved: true,
timeAchieved: nowMs,
}
: {
...baseSub,
achieved: false,
timeAchieved: null,
};
await DB.insertInto("goal_sub")
.values({
goal_id: MONGO_GoalDocument.goalID,
user_id: userID,
last_interaction: null,
progress: goalSub.progress,
progress_human: goalSub.progressHuman,
out_of: goalSub.outOf,
out_of_human: goalSub.outOfHuman,
achieved: goalSub.achieved,
time_achieved: result.achieved ? nowIso : null,
was_instantly_achieved: goalSub.wasInstantlyAchieved,
was_assigned_standalone: goalSub.wasAssignedStandalone,
})
.execute();
return goalSub;
}
export function GetQuestsThatContainGoal(goalID: string) {
return MONGODB_KILL.quests.find({
"questData.goals.goalID": goalID,
});
export async function GetQuestsThatContainGoal(goalID: string): Promise<Array<MONGO_QuestDocument>> {
const rows = await DB.selectFrom("quest")
.selectAll()
.where(
sql<boolean>`exists (
select 1
from jsonb_array_elements(quest.quest_data) as section,
lateral jsonb_array_elements(coalesce(section->'goals', '[]'::jsonb)) as g
where g->>'goalID' = ${goalID}
)`,
)
.execute();
return rows.map(ToQuestDocument);
}
/**
@@ -432,42 +476,54 @@ export function GetQuestsThatContainGoal(goalID: string) {
export async function GetQuestSubsWhichDependOnThisGoalSub(
goalSub: MONGO_GoalSubscriptionDocument,
): Promise<Array<{ quest: MONGO_QuestDocument } & MONGO_QuestSubscriptionDocument>> {
const dependencies: Array<{ quest: MONGO_QuestDocument } & MONGO_QuestSubscriptionDocument> =
await MONGODB_KILL["quest-subs"].aggregate([
{
// find all quests that this user is subscribed to
$match: {
userID: goalSub.userID,
game: goalSub.game,
playtype: goalSub.playtype,
},
},
{
// look up the parent quests
$lookup: {
from: "quests",
localField: "questID",
foreignField: "questID",
as: "parentQuestSubs",
},
},
{
// then project it onto the $quest field. This will be null
// if the quest has no parent, which we hopefully won't have
// to consider (illegal)
$set: {
quest: { $arrayElemAt: ["$parentQuestSubs", 0] },
},
},
{
// then finally, filter to only quests that pertain to this goal.
$match: {
"quest.questData.goals.goalID": goalSub.goalID,
},
},
]);
const v3Game = GamePTToV3(goalSub.game, goalSub.playtype);
return dependencies;
const subRows = await DB.selectFrom("quest_sub")
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
.selectAll("quest_sub")
.select("quest.game as quest_game")
.where("quest_sub.user_id", "=", goalSub.userID)
.where("quest.game", "=", v3Game)
.where(
sql<boolean>`exists (
select 1
from jsonb_array_elements(quest.quest_data) as section,
lateral jsonb_array_elements(coalesce(section->'goals', '[]'::jsonb)) as g
where g->>'goalID' = ${goalSub.goalID}
)`,
)
.execute();
const questIds = [...new Set(subRows.map((r) => r.quest_id))];
const questRows =
questIds.length === 0
? []
: await DB.selectFrom("quest").selectAll().where("id", "in", questIds).execute();
const questById = new Map(questRows.map((q) => [q.id, ToQuestDocument(q)]));
return subRows.map((r) => {
const quest = questById.get(r.quest_id);
if (!quest) {
throw new Error(`quest ${r.quest_id} missing after join`);
}
return {
quest,
...ToQuestSubscriptionDocument({
quest_id: r.quest_id,
user_id: r.user_id,
progress: r.progress,
last_interaction: r.last_interaction,
achieved: r.achieved,
time_achieved: r.time_achieved,
was_instantly_achieved: r.was_instantly_achieved,
quest_game: r.quest_game as Game,
}),
};
});
}
/**
@@ -489,11 +545,9 @@ export async function UnsubscribeFromGoal(
switch (dependencies.reason) {
case "HAS_QUEST_DEPENDENCIES":
// never remove a goalSub if it has quests depending on it
return dependencies;
case "WAS_STANDALONE": {
// only prevent standalone removal if we're told to
if (preventStandaloneRemoval) {
return dependencies;
}
@@ -501,15 +555,13 @@ export async function UnsubscribeFromGoal(
break;
}
// no handling necessary, orphaned goals should never happen.
case "WAS_ORPHAN":
}
// if we have no reason to prevent the removal, remove it.
await MONGODB_KILL["goal-subs"].remove({
userID: goalSub.userID,
goalID: goalSub.goalID,
});
await DB.deleteFrom("goal_sub")
.where("goal_id", "=", goalSub.goalID)
.where("user_id", "=", goalSub.userID)
.execute();
return null;
}
@@ -552,7 +604,22 @@ export async function UnsubscribeFromOrphanedGoalSubs(
game: GameGroup,
playtype: Playtype,
) {
const goalSubs = await MONGODB_KILL["goal-subs"].find({ game, playtype, userID });
const v3Game = GamePTToV3(game, playtype);
const subRows = await DB.selectFrom("goal_sub")
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
.selectAll("goal_sub")
.select("goal.game as goal_game")
.where("goal_sub.user_id", "=", userID)
.where("goal.game", "=", v3Game)
.execute();
const goalSubs = subRows.map((r) =>
ToGoalSubscriptionDocument({
...r,
goal_game: r.goal_game as Game,
}),
);
const maybeToRemove = await Promise.all(
goalSubs.map(async (goalSub) => {
@@ -566,8 +633,7 @@ export async function UnsubscribeFromOrphanedGoalSubs(
}),
);
// impressive that ts can't resolve this without a cast
const toRemove = maybeToRemove.filter((e) => e !== null) as Array<string>;
const toRemove = maybeToRemove.filter((e): e is string => e !== null);
if (toRemove.length > 0) {
log.info(
@@ -577,10 +643,10 @@ export async function UnsubscribeFromOrphanedGoalSubs(
)} as they were orphanned.`,
);
await MONGODB_KILL["goal-subs"].remove({
userID,
goalID: { $in: toRemove },
});
await DB.deleteFrom("goal_sub")
.where("user_id", "=", userID)
.where("goal_id", "in", toRemove)
.execute();
}
}
@@ -605,23 +671,34 @@ export async function GetRelevantGoals(
goals: Array<MONGO_GoalDocument>;
goalSubsMap: Map<string, MONGO_GoalSubscriptionDocument>;
}> {
const gsQuery: FilterQuery<MONGO_GoalSubscriptionDocument> = {
game,
userID,
};
const v3GamesForGroup = v3AllGames.filter((v) => V3ToGameGroup(v) === game);
let q = DB.selectFrom("goal_sub")
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
.selectAll("goal_sub")
.select("goal.game as goal_game")
.where("goal_sub.user_id", "=", userID)
.where("goal.game", "in", v3GamesForGroup);
if (onlyUnachieved) {
gsQuery.achieved = false;
q = q.where("goal_sub.achieved", "=", false);
}
const goalSubs = await MONGODB_KILL["goal-subs"].find(gsQuery);
const subRows = await q.execute();
log.debug(`Found user has ${goalSubs.length} goals.`);
log.debug(`Found user has ${subRows.length} goals.`);
if (!goalSubs.length) {
if (!subRows.length) {
return { goals: [], goalSubsMap: new Map() };
}
const goalSubs = subRows.map((r) =>
ToGoalSubscriptionDocument({
...r,
goal_game: r.goal_game as Game,
}),
);
const goalIDs = goalSubs.map((e) => e.goalID);
const chartIDsArr: Array<string> = [];
@@ -630,18 +707,25 @@ export async function GetRelevantGoals(
chartIDsArr.push(c);
}
const promises = [
// this gets the relevantGoals for direct and multi
MONGODB_KILL.goals.find({
"charts.type": { $in: ["single", "multi"] },
"charts.data": { $in: chartIDsArr },
goalID: { $in: goalIDs },
}),
GetRelevantFolderGoals(goalIDs, chartIDsArr),
];
const goalRows = await DB.selectFrom("goal").selectAll().where("goal.id", "in", goalIDs).execute();
const goals = await Promise.all(promises).then((r) => r.flat(1));
const directMulti: Array<MONGO_GoalDocument> = [];
for (const g of goalRows) {
const doc = ToGoalDocument(g);
if (doc.charts.type === "single" && chartIDsArr.includes(doc.charts.data)) {
directMulti.push(doc);
} else if (doc.charts.type === "multi") {
if (doc.charts.data.some((c) => chartIDsArr.includes(c))) {
directMulti.push(doc);
}
}
}
const folderGoals = await GetRelevantFolderGoals(goalIDs, chartIDsArr);
const goals = [...directMulti, ...folderGoals];
const goalSet = new Set(goals.map((e) => e.goalID));
const goalSubsMap: Map<string, MONGO_GoalSubscriptionDocument> = new Map();
@@ -651,8 +735,6 @@ export async function GetRelevantGoals(
continue;
}
// since these are guaranteed to be unique, lets make a hot map of goalID -> goalSubDocument, so we can
// pull them in for post-processing and filter out the goalSubDocuments that aren't relevant.
goalSubsMap.set(goalSub.goalID, goalSub);
}
@@ -667,39 +749,21 @@ export async function GetRelevantGoals(
* of chartIDsArr.
*/
export async function GetRelevantFolderGoals(goalIDs: Array<string>, chartIDsArr: Array<string>) {
// Slightly black magic - this is kind of like doing an SQL join.
// it's weird to do this in mongodb, but this seems like the right
// way to actually handle this.
if (goalIDs.length === 0 || chartIDsArr.length === 0) {
return [];
}
const result: Array<MONGO_GoalDocument> = await MONGODB_KILL.goals.aggregate([
{
$match: {
"charts.type": "folder",
goalID: { $in: goalIDs },
},
},
{
$lookup: {
from: "folder-chart-lookup",
localField: "charts.data",
foreignField: "folderID",
as: "folderCharts",
},
},
{
$match: {
"folderCharts.chartID": { $in: chartIDsArr },
},
},
{
$project: {
folderCharts: 0,
_id: 0,
},
},
]);
const rows = await DB.selectFrom("goal")
.innerJoin("folder_chart_lookup", (join) =>
join.on(sql`folder_chart_lookup.folder_id`, "=", sql`goal.charts->>'data'`),
)
.selectAll("goal")
.where(sql`goal.charts->>'type'`, "=", "folder")
.where("goal.id", "in", goalIDs)
.where("folder_chart_lookup.chart_id", "in", chartIDsArr)
.execute();
return result;
return rows.map(ToGoalDocument);
}
/**
@@ -717,17 +781,8 @@ export async function EditGoal(oldGoal: MONGO_GoalDocument, newGoal: MONGO_GoalD
newGoal.goalID = newGoalID;
await MONGODB_KILL["goal-subs"].update(
{
goalID: oldGoal.goalID,
},
{
$set: { goalID: newGoalID },
},
{ multi: true },
);
await DB.updateTable("goal_sub").set({ goal_id: newGoalID }).where("goal_id", "=", oldGoal.goalID).execute();
// update any dangling quest references
const quests = await GetQuestsThatContainGoal(oldGoal.goalID);
for (const quest of quests) {
@@ -750,15 +805,13 @@ export async function EditGoal(oldGoal: MONGO_GoalDocument, newGoal: MONGO_GoalD
});
}
await MONGODB_KILL.quests.update(
{ questID: quest.questID },
{
$set: { questData: newQuestData },
},
);
await DB.updateTable("quest")
.set({ quest_data: newQuestData })
.where("quest.id", "=", quest.questID)
.execute();
}
await MONGODB_KILL.goals.remove({ goalID: oldGoal.goalID });
await DB.deleteFrom("goal").where("goal.id", "=", oldGoal.goalID).execute();
newGoal.name = await CreateGoalName(
newGoal.charts,
@@ -766,9 +819,20 @@ export async function EditGoal(oldGoal: MONGO_GoalDocument, newGoal: MONGO_GoalD
newGoal.game,
newGoal.playtype,
);
const v3Game = GamePTToV3(newGoal.game, newGoal.playtype);
try {
await MONGODB_KILL.goals.insert(newGoal);
} catch (err) {
await DB.insertInto("goal")
.values({
id: newGoal.goalID,
game: v3Game,
name: newGoal.name,
charts: newGoal.charts,
criteria: newGoal.criteria,
})
.execute();
} catch {
log.info(`Goal ${newGoal.name} already existed.`);
}
}
+177 -121
View File
@@ -9,9 +9,19 @@ import type {
} from "tachi-common";
import { SubscribeFailReasons } from "#lib/constants/err-codes";
import {
ToGoalDocument,
ToGoalSubscriptionDocument,
ToQuestDocument,
ToQuestSubscriptionDocument,
} from "#lib/db-formats/target-documents";
import { log } from "#lib/log/log";
import { BulkSendNotification } from "#lib/notifications/notifications";
import MONGODB_KILL from "#services/mongo/db";
import DB from "#services/pg/db";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import { sql } from "kysely";
import type { Game } from "tachi-db";
import { GamePTToV3, V3ToGamePT } from "tachi-common";
import {
type EvaluatedGoalReturn,
@@ -26,8 +36,6 @@ import {
* nested structure of quests.
*/
export function GetGoalIDsFromQuest(quest: MONGO_QuestDocument) {
// this sucks - maybe a nicer way to do this, because nested
// maps are just ugly
return quest.questData.map((e) => e.goals.map((e) => e.goalID)).flat(1);
}
@@ -37,9 +45,7 @@ export function GetGoalIDsFromQuest(quest: MONGO_QuestDocument) {
export async function GetGoalsInQuest(quest: MONGO_QuestDocument) {
const goalIDs = GetGoalIDsFromQuest(quest);
const goals = await MONGODB_KILL.goals.find({
goalID: { $in: goalIDs },
});
const goals = await DB.selectFrom("goal").selectAll().where("goal.id", "in", goalIDs).execute();
if (goals.length !== goalIDs.length) {
log.error(
@@ -49,7 +55,6 @@ export async function GetGoalsInQuest(quest: MONGO_QuestDocument) {
throw new Error(`Quest is corrupt. Not the right amount of goals in db?`);
}
// this shouldn't happen, but if it does it's recoverable by just ignoring it.
if (goalIDs.length < 2) {
log.warn(
{
@@ -59,7 +64,7 @@ export async function GetGoalsInQuest(quest: MONGO_QuestDocument) {
);
}
return goals;
return goals.map(ToGoalDocument);
}
/**
@@ -68,11 +73,13 @@ export async function GetGoalsInQuest(quest: MONGO_QuestDocument) {
export async function GetGoalsInQuests(quests: Array<MONGO_QuestDocument>) {
const goalIDs = quests.flatMap((quest) => GetGoalIDsFromQuest(quest));
const goals = await MONGODB_KILL.goals.find({
goalID: { $in: goalIDs },
});
if (goalIDs.length === 0) {
return [];
}
return goals;
const goals = await DB.selectFrom("goal").selectAll().where("goal.id", "in", goalIDs).execute();
return goals.map(ToGoalDocument);
}
/**
@@ -98,23 +105,35 @@ type EvaluatedGoalResult = { goalID: string } & EvaluatedGoalReturn;
export async function EvaluateQuestProgress(userID: integer, quest: MONGO_QuestDocument) {
const goals = await GetGoalsInQuest(quest);
const isSubscribedToQuest = await MONGODB_KILL["quest-subs"].findOne({
questID: quest.questID,
userID,
});
const isSubscribedToQuest = await DB.selectFrom("quest_sub")
.selectAll()
.where("quest_sub.quest_id", "=", quest.questID)
.where("quest_sub.user_id", "=", userID)
.executeTakeFirst();
// If the user is subscribed the quest, we don't need to calculate
// their progress on each goal.
const goalSubMap = new Map<string, MONGO_GoalSubscriptionDocument>();
if (isSubscribedToQuest) {
const goalSubs = await MONGODB_KILL["goal-subs"].find({
goalID: { $in: goals.map((e) => e.goalID) },
userID,
});
const goalSubRows = await DB.selectFrom("goal_sub")
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
.selectAll("goal_sub")
.select("goal.game as goal_game")
.where("goal_sub.user_id", "=", userID)
.where(
"goal_sub.goal_id",
"in",
goals.map((e) => e.goalID),
)
.execute();
for (const sub of goalSubs) {
goalSubMap.set(sub.goalID, sub);
for (const row of goalSubRows) {
goalSubMap.set(
row.goal_id,
ToGoalSubscriptionDocument({
...row,
goal_game: row.goal_game as Game,
}),
);
}
}
@@ -124,8 +143,6 @@ export async function EvaluateQuestProgress(userID: integer, quest: MONGO_QuestD
let goalSub = goalSubMap.get(goal.goalID);
if (!goalSub) {
// shouldn't happen. Let's just correct the user silently.
log.warn(
`User ${userID} has a corrupt subscription to quest '${quest.name}', They do not have all the goals in this quest assigned. Automatically subscribing them to the new goal.`,
);
@@ -142,7 +159,6 @@ export async function EvaluateQuestProgress(userID: integer, quest: MONGO_QuestD
}
if (newGoalSub === SubscribeFailReasons.ALREADY_ACHIEVED) {
// lol, wut
log.error(
`Impossible via typesystem: attempted resubscription for user ${userID} on goal ${goal.goalID}, was rejected for being already achieved. Not possible, as we allow already achieved goals here.`,
);
@@ -155,12 +171,14 @@ export async function EvaluateQuestProgress(userID: integer, quest: MONGO_QuestD
goalSub = newGoalSub;
}
const gSub = goalSub;
return {
achieved: goalSub.achieved,
progress: goalSub.progress,
outOf: goalSub.outOf,
progressHuman: goalSub.progressHuman,
outOfHuman: goalSub.outOfHuman,
achieved: gSub.achieved,
progress: gSub.progress,
outOf: gSub.outOf,
progressHuman: gSub.progressHuman,
outOfHuman: gSub.outOfHuman,
goalID: goal.goalID,
};
}
@@ -228,10 +246,11 @@ export async function SubscribeToQuest(
| SubscribeFailReasons.ALREADY_ACHIEVED
| SubscribeFailReasons.ALREADY_SUBSCRIBED
> {
const isSubscribedToQuest = await MONGODB_KILL["quest-subs"].findOne({
userID,
questID: quest.questID,
});
const isSubscribedToQuest = await DB.selectFrom("quest_sub")
.selectAll()
.where("quest_sub.quest_id", "=", quest.questID)
.where("quest_sub.user_id", "=", userID)
.executeTakeFirst();
if (isSubscribedToQuest) {
return SubscribeFailReasons.ALREADY_SUBSCRIBED;
@@ -243,27 +262,44 @@ export async function SubscribeToQuest(
return SubscribeFailReasons.ALREADY_ACHIEVED;
}
// @ts-expect-error TS can't resolve this.
// because it can't explode out the types.
const questSub: MONGO_QuestSubscriptionDocument = {
const nowMs = Date.now();
const nowIso = UnixMillisecondsToISO8601(nowMs);
const questSubBase = {
progress: result.progress,
userID,
questID: quest.questID,
wasInstantlyAchieved: result.achieved,
game: quest.game,
playtype: quest.playtype,
achieved: result.achieved,
timeAchieved: result.achieved ? Date.now() : null,
lastInteraction: null,
};
// @optimisable, EvaluateQuestProgress calculates the users progress
// on each goal. We could probably shorten this by directly inserting the records
// from result.goalResults ourselves.
// evaluating goals is fairly cheap though.
const questSub: MONGO_QuestSubscriptionDocument = result.achieved
? {
...questSubBase,
achieved: true,
timeAchieved: nowMs,
}
: {
...questSubBase,
achieved: false,
timeAchieved: null,
};
await Promise.all(result.goals.map((goal) => SubscribeToGoal(userID, goal, false)));
await MONGODB_KILL["quest-subs"].insert(questSub);
await DB.insertInto("quest_sub")
.values({
quest_id: quest.questID,
user_id: userID,
progress: questSub.progress,
last_interaction: null,
achieved: questSub.achieved,
time_achieved: result.achieved ? nowIso : null,
was_instantly_achieved: questSub.wasInstantlyAchieved,
})
.execute();
log.info(`User ${userID} subscribed to '${quest.name}'.`);
@@ -282,25 +318,24 @@ export async function SubscribeToQuest(
export async function UpdateQuestSubscriptions(questID: string) {
log.info(`Received update-subscribe call to quest ${questID}.`);
const subscriptions = await MONGODB_KILL["quest-subs"].find({ questID });
const subscriptions = await DB.selectFrom("quest_sub")
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
.selectAll("quest_sub")
.select("quest.game as quest_game")
.where("quest_sub.quest_id", "=", questID)
.execute();
const maybeQuest = await MONGODB_KILL.quests.findOne({ questID });
const maybeQuest = await DB.selectFrom("quest").selectAll().where("quest.id", "=", questID).executeTakeFirst();
// if the quest was deleted, we have to take a more manual approach.
if (!maybeQuest) {
// first, remove all subs to this quest
await MONGODB_KILL["quest-subs"].remove({
questID,
});
await DB.deleteFrom("quest_sub").where("quest_sub.quest_id", "=", questID).execute();
// then, this presents us with an interesting problem.
// We can't actually know what goals this user was subscribed to as a result
// of this quest, because said quest no longer exists.
// To mitigate this, we just prune all goalsubs that no longer have any
// dependencies
await Promise.all(
subscriptions.map((e) => UnsubscribeFromOrphanedGoalSubs(e.userID, e.game, e.playtype)),
subscriptions.map((e) => {
const { game, playtype } = V3ToGamePT(e.quest_game as Game);
return UnsubscribeFromOrphanedGoalSubs(e.user_id, game, playtype);
}),
);
log.info(`Quest ${questID} has been deleted. Unsubscribed ${subscriptions.length} users.`);
@@ -308,21 +343,34 @@ export async function UpdateQuestSubscriptions(questID: string) {
return;
}
// the easiest way to do this? unsubscribe all users from the quest, then subscribe
// them all again.
await Promise.all(subscriptions.map((e) => UnsubscribeFromQuest(e, maybeQuest)));
const quest = ToQuestDocument(maybeQuest);
await Promise.all(subscriptions.map((e) => SubscribeToQuest(e.userID, maybeQuest, false)));
const mappedSubs = subscriptions.map((e) =>
ToQuestSubscriptionDocument({
quest_id: e.quest_id,
user_id: e.user_id,
progress: e.progress,
last_interaction: e.last_interaction,
achieved: e.achieved,
time_achieved: e.time_achieved,
was_instantly_achieved: e.was_instantly_achieved,
quest_game: e.quest_game as Game,
}),
);
await Promise.all(mappedSubs.map((e) => UnsubscribeFromQuest(e, quest)));
await Promise.all(mappedSubs.map((e) => SubscribeToQuest(e.userID, quest, false)));
await BulkSendNotification(
`The quest '${maybeQuest.name}' has received an update.`,
subscriptions.map((e) => e.userID),
`The quest '${quest.name}' has received an update.`,
subscriptions.map((e) => e.user_id),
{
type: "QUEST_CHANGED",
content: {
questID,
game: maybeQuest.game,
playtype: maybeQuest.playtype,
game: quest.game,
playtype: quest.playtype,
},
},
);
@@ -340,20 +388,26 @@ export async function UnsubscribeFromQuest(
) {
const goalIDs = GetGoalIDsFromQuest(quest);
// remove the quest sub
// (preventing HAS_QUEST_DEPENDENCIES when this is the quest we're removing anyway)
await MONGODB_KILL["quest-subs"].remove({
questID: questSub.questID,
userID: questSub.userID,
});
await DB.deleteFrom("quest_sub")
.where("quest_sub.quest_id", "=", questSub.questID)
.where("quest_sub.user_id", "=", questSub.userID)
.execute();
const goalSubs = await MONGODB_KILL["goal-subs"].find({
userID: questSub.userID,
goalID: { $in: goalIDs },
});
const goalSubRows = await DB.selectFrom("goal_sub")
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
.selectAll("goal_sub")
.select("goal.game as goal_game")
.where("goal_sub.user_id", "=", questSub.userID)
.where("goal_sub.goal_id", "in", goalIDs)
.execute();
const goalSubs = goalSubRows.map((r) =>
ToGoalSubscriptionDocument({
...r,
goal_game: r.goal_game as Game,
}),
);
// unsub the user from all goals we can. If we can't unsub from a goal, that's
// not a problem, we weren't meant to unsubscribe from it.
await Promise.all(goalSubs.map((e) => UnsubscribeFromGoal(e, true)));
}
@@ -367,55 +421,57 @@ export async function GetParentQuests(
playtype: Playtype,
goalSubs: Array<MONGO_GoalSubscriptionDocument>,
) {
const questSubs: Array<{ questID: string }> = await MONGODB_KILL["quest-subs"].find(
{
game,
playtype,
userID,
},
{
projection: {
questID: 1,
},
},
);
const v3Game = GamePTToV3(game, playtype);
const goalIds = goalSubs.map((e) => e.goalID);
const questSubIDs = questSubs.map((e) => e.questID);
if (goalIds.length === 0) {
return [];
}
const quests = await MONGODB_KILL.quests.find({
questID: { $in: questSubIDs },
"questData.goals.goalID": { $in: goalSubs.map((e) => e.goalID) },
});
const questSubRows = await DB.selectFrom("quest_sub")
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
.select("quest_sub.quest_id")
.where("quest_sub.user_id", "=", userID)
.where("quest.game", "=", v3Game)
.execute();
return quests;
const questSubIDs = questSubRows.map((e) => e.quest_id);
if (questSubIDs.length === 0) {
return [];
}
const questRows = await DB.selectFrom("quest")
.selectAll()
.where("quest.id", "in", questSubIDs)
.execute();
return questRows
.filter((q) => {
const doc = ToQuestDocument(q);
return doc.questData.some((section) =>
section.goals.some((g) => goalIds.includes(g.goalID)),
);
})
.map(ToQuestDocument);
}
/**
* Find all quests not in any questlines.
*/
export async function FindStandaloneQuests(game: GameGroup, playtype: Playtype) {
const res: Array<MONGO_QuestDocument> = await MONGODB_KILL.quests.aggregate([
{
$match: {
game,
playtype,
},
},
{
$lookup: {
from: "questlines",
localField: "questID",
foreignField: "quests",
as: "parentQuestlines",
},
},
{
$match: {
// is an empty array
"parentQuestlines.0": { $exists: false },
},
},
]);
const v3Game = GamePTToV3(game, playtype);
return res;
const rows = await DB.selectFrom("quest")
.selectAll()
.where("quest.game", "=", v3Game)
.where(
sql<boolean>`not exists (
select 1 from questline_quest qq where qq.quest_id = quest.id
)`,
)
.execute();
return rows.map(ToQuestDocument);
}
-4
View File
@@ -7,8 +7,6 @@ import { HandleSIGTERMGracefully } from "#lib/handlers/sigterm";
import { log } from "#lib/log/log";
import { Env, ServerConfig, TachiConfig } from "#lib/setup/config";
import server from "#server/server";
import { monkDB } from "#services/mongo/db";
import { UpdateIndexes } from "#services/mongo/indexes";
import DB from "#services/pg/db";
import fetch from "#utils/fetch";
import { GetUserWithID } from "#utils/user";
@@ -29,8 +27,6 @@ log.info({ bootInfo: true }, `Log level is set to ${Env.LOG_LEVEL}.`);
log.info({ bootInfo: true }, `Loading sequence documents...`);
async function RunOnInit() {
await UpdateIndexes(monkDB, false);
await applyMigrations(Env.POSTGRES_URL, Env.MIGRATIONS_DIR);
if (Env.NODE_ENV === "dev") {
@@ -1001,9 +1001,15 @@ async function main(): Promise<void> {
const uniqueGoalIds = [...new Set(goalSubs.map((gs) => gs.goalID))];
const existingGoalIds = new Set(
(
await pg.selectFrom("goal").select("id").where("id", "in", uniqueGoalIds).execute()
).map((r) => r.id),
uniqueGoalIds.length === 0
? []
: (
await pg
.selectFrom("goal")
.select("id")
.where("id", "in", uniqueGoalIds)
.execute()
).map((r) => r.id),
);
const goalSubRows: Array<NewGoalSub> = [];
@@ -12,12 +12,12 @@ async function seedBmsSongAndChart(opts: {
sglHC: number | null;
}) {
const n = ++seedCounter;
const songPgId = `s_gpt_bms_${n}`;
const songNewID = `s_gpt_bms_${n}`;
const chartPgId = `c_gpt_bms_${n}`;
await DB.insertInto("song")
.values({
id: songPgId,
id: songNewID,
legacy_id: 970_000 + n,
game_group: "bms",
title: `Sieglinde GPT Test ${n}`,
@@ -42,7 +42,7 @@ async function seedBmsSongAndChart(opts: {
id: chartPgId,
legacy_id: chartPgId,
game: opts.game,
song_id: songPgId,
song_id: songNewID,
level: "?",
level_num: 0,
is_primary: true,
@@ -1,21 +1,24 @@
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
import { GetSongsByLegacyIDs } from "#lib/db-formats/song";
import { GetUGPTSettingsDocument } from "#lib/db-formats/ugpt-settings";
import { log } from "#lib/log/log";
import { ResolveSongAndChart } from "#lib/score-import/import-types/common/batch-manual/converter";
import { SearchSpecificGameSongs } from "#lib/search/songs.js";
import prValidate from "#server/middleware/prudence-validate";
import MONGODB_KILL from "#services/mongo/db";
import DB from "#services/pg/db";
import { IsString } from "#utils/misc";
import { FindChartsOnPopularity } from "#utils/queries/charts";
import { GetGPT } from "#utils/req-tachi-data";
import { Router } from "express";
import {
GamePTToV3,
type integer,
type MatchTypeResolver,
type MONGO_ChartDocument,
type MONGO_UGPTSettingsDocument,
} from "tachi-common";
import { PR_RESOLVER } from "tachi-common/lib/schemas";
import { type Game } from "tachi-db";
import chartIDRouter from "./_chartID/router";
@@ -55,12 +58,16 @@ router.get("/", async (req, res) => {
});
}
const playedSongs = (
await MONGODB_KILL["personal-bests"].find(
{ userID, game, playtype },
{ projection: { songID: 1 } },
)
).map((e) => e.songID);
const v3Game = GamePTToV3(game, playtype) as Game;
const playedSongRows = await DB.selectFrom("pb")
.innerJoin("chart", "chart.id", "pb.chart_id")
.innerJoin("song", "song.id", "chart.song_id")
.select("song.legacy_id as song_legacy_id")
.distinct()
.where("pb.user_id", "=", userID)
.where("chart.game", "=", v3Game)
.execute();
const playedSongs = playedSongRows.map((e) => e.song_legacy_id);
if (songIDs) {
songIDs = songIDs.filter((e) => playedSongs.includes(e));
@@ -104,13 +111,16 @@ router.get("/", async (req, res) => {
(e) => (e as MONGO_ChartDocument<"iidx:DP" | "iidx:SP">).data["2dxtraSet"] === null,
);
} else {
const iidxSettings = (await MONGODB_KILL["game-settings"].findOne({
userID: req[SYMBOL_TACHI_API_AUTH].userID,
const iidxSettings = await GetUGPTSettingsDocument(
req[SYMBOL_TACHI_API_AUTH].userID,
game,
playtype,
})) as MONGO_UGPTSettingsDocument<"iidx:DP" | "iidx:SP"> | null;
);
if (!iidxSettings?.preferences.gameSpecific.display2DXTra) {
const iidxGameSpecific = iidxSettings?.preferences
.gameSpecific as MONGO_UGPTSettingsDocument<"iidx:DP" | "iidx:SP">["preferences"]["gameSpecific"];
if (!iidxGameSpecific?.display2DXTra) {
charts = charts.filter(
(e) =>
(e as MONGO_ChartDocument<"iidx:DP" | "iidx:SP">).data["2dxtraSet"] ===
@@ -1,10 +1,9 @@
import type { FindOptions } from "monk";
import { CreateActivityRouteHandler } from "#lib/activity/activity";
import { ONE_HOUR } from "#lib/constants/time";
import { SELECT_GAME_PROFILE, ToGameStatsDocument } from "#lib/db-formats/game-profiles.js";
import { LoadPbDocumentsForGameSortedByCalculatedAlg } from "#lib/db-formats/pb";
import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user.js";
import prValidate from "#server/middleware/prudence-validate";
import MONGODB_KILL from "#services/mongo/db";
import DB from "#services/pg/db.js";
import { GetRelevantSongsAndCharts } from "#utils/db";
import { EscapeForILIKE, IsString } from "#utils/misc";
@@ -16,6 +15,7 @@ import {
} from "#utils/string-checks";
import { GetUsersWithIDs } from "#utils/user";
import { Router } from "express";
import { sql } from "kysely";
import NodeCache from "node-cache";
import {
FormatGameGroup,
@@ -23,7 +23,6 @@ import {
GamePTToV3,
GetGamePTConfig,
type integer,
type MONGO_UserGameStats,
type Playtype,
} from "tachi-common";
@@ -47,16 +46,24 @@ async function GetGameStats(
const cacheRes = gptStatCache.get(`${game}:${playtype}`);
if (cacheRes === undefined) {
const v3Game = GamePTToV3(game, playtype);
const [scoreCount, playerCount, chartCount] = await Promise.all([
MONGODB_KILL.scores.count({
game,
playtype,
}),
MONGODB_KILL["game-stats"].count({
game,
playtype,
}),
MONGODB_KILL.anyCharts[game].count({ playtype }),
DB.selectFrom("score")
.select((eb) => eb.fn.countAll().as("c"))
.where("score.game", "=", v3Game)
.executeTakeFirst()
.then((r) => Number(r?.c ?? 0)),
DB.selectFrom("game_profile")
.select((eb) => eb.fn.countAll().as("c"))
.where("game_profile.game", "=", v3Game)
.executeTakeFirst()
.then((r) => Number(r?.c ?? 0)),
DB.selectFrom("chart")
.select((eb) => eb.fn.countAll().as("c"))
.where("chart.game", "=", v3Game)
.executeTakeFirst()
.then((r) => Number(r?.c ?? 0)),
]);
gptStatCache.set(`${game}:${playtype}`, { scoreCount, playerCount, chartCount }, ONE_HOUR);
@@ -128,20 +135,16 @@ router.get("/leaderboard", async (req, res) => {
alg = temp;
}
const options: FindOptions<MONGO_UserGameStats> = {
sort: {
[`ratings.${alg}`]: -1,
},
limit,
};
const v3Game = GamePTToV3(game, playtype);
const ratingCol = sql<number>`coalesce((game_profile.ratings::jsonb->>${sql.lit(alg)})::numeric, 0)`;
const gameStats = await MONGODB_KILL["game-stats"].find(
{
game,
playtype,
},
options,
);
const gameStats = await DB.selectFrom("game_profile")
.select(SELECT_GAME_PROFILE)
.where("game_profile.game", "=", v3Game)
.orderBy(ratingCol, "desc")
.limit(limit)
.execute()
.then((rows) => rows.map(ToGameStatsDocument));
const users = await GetUsersWithIDs(gameStats.map((e) => e.userID));
@@ -193,18 +196,8 @@ router.get("/pb-leaderboard", async (req, res) => {
alg = temp;
}
const pbs = await MONGODB_KILL["personal-bests"].find(
{
game,
playtype,
},
{
sort: {
[`calculatedData.${alg}`]: -1,
},
limit,
},
);
const v3Game = GamePTToV3(game, playtype);
const pbs = await LoadPbDocumentsForGameSortedByCalculatedAlg(v3Game, alg, limit);
const users = await GetUsersWithIDs(pbs.map((e) => e.userID));
@@ -25,7 +25,7 @@ export const ValidateAndGetSong: RequestHandler = async (req, res, next) => {
});
}
AssignToReqTachiData(req, { songDoc: result.doc, songPgId: result.pgId });
AssignToReqTachiData(req, { songDoc: result.doc, songNewID: result.newSongID });
next();
};
@@ -39,7 +39,7 @@ async function seedSong({
async function seedChart({
pgId = CHART_PG_ID,
legacyId = CHART_LEGACY_ID,
songPgId = SONG_PG_ID,
songNewID = SONG_PG_ID,
difficulty = "ANOTHER",
level = "10",
levelNum = 10,
@@ -51,7 +51,7 @@ async function seedChart({
id: pgId,
legacy_id: legacyId,
game: "iidx-sp",
song_id: songPgId,
song_id: songNewID,
difficulty,
level,
level_num: levelNum,
@@ -1,4 +1,4 @@
import { GetChartsBySongPgId } from "#lib/db-formats/chart";
import { GetChartsBySongId } from "#lib/db-formats/chart";
import { GetGPT, GetTachiData } from "#utils/req-tachi-data";
import { Router } from "express";
import { GamePTToV3 } from "tachi-common";
@@ -16,10 +16,10 @@ router.use(ValidateAndGetSong);
*/
router.get("/", async (req, res) => {
const song = GetTachiData(req, "songDoc");
const songPgId = GetTachiData(req, "songPgId");
const songNewID = GetTachiData(req, "songNewID");
const { game, playtype } = GetGPT(req);
const charts = await GetChartsBySongPgId(GamePTToV3(game, playtype), songPgId, song.id);
const charts = await GetChartsBySongId(GamePTToV3(game, playtype), songNewID);
return res.status(200).json({
success: true,
@@ -1,6 +1,5 @@
import { GetTableDocumentsForGamePlaytype } from "#lib/db-formats/table";
import { log } from "#lib/log/log";
import MONGODB_KILL from "#services/mongo/db";
import { GetFoldersFromTable } from "#utils/folder";
import { GetGPT, GetTachiData } from "#utils/req-tachi-data";
import { Router } from "express";
@@ -1,15 +1,17 @@
import type { MONGO_GoalDocument } from "tachi-common";
import { log } from "#lib/log/log";
import { ToGoalDocument, ToGoalSubscriptionDocument } from "#lib/db-formats/target-documents";
import { CreateGoalTitle, ValidateGoalChartsAndCriteria } from "#lib/targets/goal-utils";
import { GetQuestsThatContainGoal } from "#lib/targets/goals";
import prValidate from "#server/middleware/prudence-validate";
import MONGODB_KILL from "#services/mongo/db";
import DB from "#services/pg/db";
import { GetMostSubscribedGoals } from "#utils/db";
import { AssignToReqTachiData, GetGPT, GetTachiData } from "#utils/req-tachi-data";
import { GetUsersWithIDs } from "#utils/user";
import { type RequestHandler, Router } from "express";
import { p } from "prudence";
import { GamePTToV3 } from "tachi-common";
import type { Game } from "tachi-db";
const router: Router = Router({ mergeParams: true });
@@ -131,20 +133,18 @@ const ResolveGoalID: RequestHandler = async (req, res, next) => {
const { game, playtype } = GetGPT(req);
const goalID = req.params.goalID;
const goal = await MONGODB_KILL.goals.findOne({
goalID,
game,
playtype,
});
const v3Game = GamePTToV3(game, playtype);
if (!goal) {
const row = await DB.selectFrom("goal").selectAll().where("goal.id", "=", goalID).where("goal.game", "=", v3Game).executeTakeFirst();
if (!row) {
return res.status(404).json({
success: false,
description: `A goal with ID ${goalID} doesn't exist.`,
});
}
AssignToReqTachiData(req, { goalDoc: goal });
AssignToReqTachiData(req, { goalDoc: ToGoalDocument(row) });
next();
};
@@ -157,9 +157,19 @@ const ResolveGoalID: RequestHandler = async (req, res, next) => {
router.get("/:goalID", ResolveGoalID, async (req, res) => {
const goal = GetTachiData(req, "goalDoc");
const goalSubs = await MONGODB_KILL["goal-subs"].find({
goalID: goal.goalID,
});
const subRows = await DB.selectFrom("goal_sub")
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
.selectAll("goal_sub")
.select("goal.game as goal_game")
.where("goal_sub.goal_id", "=", goal.goalID)
.execute();
const goalSubs = subRows.map((r) =>
ToGoalSubscriptionDocument({
...r,
goal_game: r.goal_game as Game,
}),
);
const users = await GetUsersWithIDs(goalSubs.map((e) => e.userID));
@@ -0,0 +1,133 @@
import DB from "#services/pg/db";
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
import { afterAll, describe, expect, it } from "vitest";
afterAll(() => CloseServerConnection());
describe("GET /api/v1/games/:game/:playtype/targets/questlines", () => {
it("returns questlines and standalone quests for the GPT", async () => {
const suffix = `${Date.now()}`;
const qlId = `ql-rt-${suffix}`;
const qIn = `q-in-${suffix}`;
const qStandalone = `q-standalone-${suffix}`;
await DB.insertInto("quest")
.values([
{
id: qIn,
game: "iidx-sp",
name: "In line",
description: "d",
quest_data: JSON.stringify([]),
},
{
id: qStandalone,
game: "iidx-sp",
name: "Standalone",
description: "d",
quest_data: JSON.stringify([]),
},
])
.execute();
await DB.insertInto("questline")
.values({
id: qlId,
game: "iidx-sp",
name: "Test line",
description: "desc",
})
.execute();
await DB.insertInto("questline_quest")
.values({
questline_id: qlId,
quest_id: qIn,
sort_order: 0,
})
.execute();
const res = await mockApi.get("/api/v1/games/iidx/SP/targets/questlines");
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
const ids = (res.body.body.questlines as Array<{ questlineID: string }>).map(
(q) => q.questlineID,
);
expect(ids).toContain(qlId);
const ql = (
res.body.body.questlines as Array<{ questlineID: string; quests: Array<string> }>
).find((x) => x.questlineID === qlId);
expect(ql?.quests).toEqual([qIn]);
const standaloneIds = (res.body.body.standalone as Array<{ questID: string }>).map(
(q) => q.questID,
);
expect(standaloneIds).toContain(qStandalone);
});
it("returns 404 when the questline is missing", async () => {
const res = await mockApi.get(
"/api/v1/games/iidx/SP/targets/questlines/no_such_questline_zzz",
);
expect(res.status).toBe(404);
expect(res.body.success).toBe(false);
});
it("returns a questline with ordered child quests", async () => {
const suffix = `${Date.now()}-b`;
const qlId = `ql-ord-${suffix}`;
const qa = `q-a-${suffix}`;
const qb = `q-b-${suffix}`;
await DB.insertInto("quest")
.values([
{
id: qa,
game: "iidx-sp",
name: "A",
description: "d",
quest_data: JSON.stringify([]),
},
{
id: qb,
game: "iidx-sp",
name: "B",
description: "d",
quest_data: JSON.stringify([]),
},
])
.execute();
await DB.insertInto("questline")
.values({
id: qlId,
game: "iidx-sp",
name: "Ordered",
description: "d",
})
.execute();
await DB.insertInto("questline_quest")
.values([
{ questline_id: qlId, quest_id: qb, sort_order: 0 },
{ questline_id: qlId, quest_id: qa, sort_order: 1 },
])
.execute();
const res = await mockApi.get(`/api/v1/games/iidx/SP/targets/questlines/${qlId}`);
expect(res.status).toBe(200);
expect(res.body.body.questline.questlineID).toBe(qlId);
const order = (res.body.body.quests as Array<{ questID: string }>).map((q) => q.questID);
expect(order).toEqual([qb, qa]);
});
});
@@ -1,7 +1,6 @@
import { FindStandaloneQuests, GetGoalsInQuests } from "#lib/targets/quests";
import MONGODB_KILL from "#services/mongo/db";
import { GetChildQuests } from "#utils/db";
import { IsString } from "#utils/misc";
import { GetQuestlineById, GetQuestlinesForGamePlaytype } from "#utils/queries/questlines";
import { AssignToReqTachiData, GetGPT, GetTachiData } from "#utils/req-tachi-data";
import { type RequestHandler, Router } from "express";
@@ -11,11 +10,7 @@ const ResolveQuestlineID: RequestHandler = async (req, res, next) => {
const { game, playtype } = GetGPT(req);
const questlineID = req.params.questlineID;
const questline = await MONGODB_KILL.questlines.findOne({
questlineID,
game,
playtype,
});
const questline = await GetQuestlineById(game, playtype, questlineID);
if (!questline) {
return res.status(404).json({
@@ -37,7 +32,7 @@ const ResolveQuestlineID: RequestHandler = async (req, res, next) => {
router.get("/", async (req, res) => {
const { game, playtype } = GetGPT(req);
const questlines = await MONGODB_KILL.questlines.find({ game, playtype });
const questlines = await GetQuestlinesForGamePlaytype(game, playtype);
const standalone = await FindStandaloneQuests(game, playtype);
const standaloneGoals = await GetGoalsInQuests(standalone);
@@ -1,31 +1,34 @@
import { SearchCollection } from "#lib/search/search";
import { ToQuestDocument, ToQuestSubscriptionDocument } from "#lib/db-formats/target-documents";
import { GetGoalsInQuest, GetGoalsInQuests } from "#lib/targets/quests";
import MONGODB_KILL from "#services/mongo/db";
import { IsString } from "#utils/misc";
import DB from "#services/pg/db";
import { EscapeForILIKE } from "#utils/misc";
import { AssignToReqTachiData, GetGPT, GetTachiData } from "#utils/req-tachi-data";
import { GetUsersWithIDs } from "#utils/user";
import { type RequestHandler, Router } from "express";
import { GamePTToV3, V3ToGamePT } from "tachi-common";
import type { Game } from "tachi-db";
const router: Router = Router({ mergeParams: true });
const ResolveQuestID: RequestHandler = async (req, res, next) => {
const { game, playtype } = GetGPT(req);
const questID = req.params.questID;
const quest = await MONGODB_KILL.quests.findOne({
questID,
game,
playtype,
});
const v3Game = GamePTToV3(game, playtype);
if (!quest) {
const row = await DB.selectFrom("quest")
.selectAll()
.where("quest.id", "=", questID)
.where("quest.game", "=", v3Game)
.executeTakeFirst();
if (!row) {
return res.status(404).json({
success: false,
description: `A quest with ID ${questID} doesn't exist.`,
});
}
AssignToReqTachiData(req, { questDoc: quest });
AssignToReqTachiData(req, { questDoc: ToQuestDocument(row) });
next();
};
@@ -40,20 +43,27 @@ const ResolveQuestID: RequestHandler = async (req, res, next) => {
router.get("/", async (req, res) => {
const { game, playtype } = GetGPT(req);
if (!IsString(req.query.search)) {
if (typeof req.query.search !== "string") {
return res.status(400).json({
success: false,
description: `Invalid value for search.`,
});
}
const quests = await SearchCollection(
MONGODB_KILL.quests,
req.query.search,
"quests",
{ game, playtype },
50,
);
const v3Game = GamePTToV3(game, playtype);
const likeEsc = EscapeForILIKE(req.query.search.trim());
const pattern = `%${likeEsc}%`;
const rows = await DB.selectFrom("quest")
.selectAll()
.where("quest.game", "=", v3Game)
.where((eb) =>
eb.or([eb("quest.name", "ilike", pattern), eb("quest.description", "ilike", pattern)]),
)
.limit(50)
.execute();
const quests = rows.map(ToQuestDocument);
const goals = await GetGoalsInQuests(quests);
return res.status(200).json({
@@ -71,16 +81,46 @@ router.get("/", async (req, res) => {
router.get("/:questID", ResolveQuestID, async (req, res) => {
const quest = GetTachiData(req, "questDoc");
const questSubs = await MONGODB_KILL["quest-subs"].find({
questID: quest.questID,
});
const questSubRows = await DB.selectFrom("quest_sub")
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
.selectAll("quest_sub")
.select("quest.game as quest_game")
.where("quest_sub.quest_id", "=", quest.questID)
.execute();
const questSubs = questSubRows.map((r) =>
ToQuestSubscriptionDocument({
quest_id: r.quest_id,
user_id: r.user_id,
progress: r.progress,
last_interaction: r.last_interaction,
achieved: r.achieved,
time_achieved: r.time_achieved,
was_instantly_achieved: r.was_instantly_achieved,
quest_game: r.quest_game as Game,
}),
);
const users = await GetUsersWithIDs(questSubs.map((e) => e.userID));
const goals = await GetGoalsInQuest(quest);
const parentQuestlines = await MONGODB_KILL.questlines.find({
quests: quest.questID,
const qlRows = await DB.selectFrom("questline")
.innerJoin("questline_quest", "questline_quest.questline_id", "questline.id")
.selectAll("questline")
.where("questline_quest.quest_id", "=", quest.questID)
.execute();
const parentQuestlines = qlRows.map((ql) => {
const { game: gg, playtype: pt } = V3ToGamePT(ql.game);
return {
questlineID: ql.id,
name: ql.name,
desc: ql.description,
game: gg,
playtype: pt,
};
});
return res.status(200).json({
@@ -162,7 +162,10 @@ router.post("/orphans", RequirePermissions("submit_score"), async (req, res) =>
log.info(`User ${FormatUserDoc(userDoc)} forced an orphan sync.`);
const { processed, removed, failed, success } = await DeorphanScores({ userID: userDoc.id }, log);
const { processed, removed, failed, success } = await DeorphanScores(
{ userID: userDoc.id },
log,
);
log.info(`Finished attempting deorphaning.`);
@@ -167,39 +167,35 @@ router.get("/:importID", GetImportFromParam, async (req, res) => {
*
* @name POST /api/v1/imports/:importID/revert
*/
router.post(
"/:importID/revert",
RequirePermissions("delete_score"),
async (req, res) => {
const auth = req[SYMBOL_TACHI_API_AUTH];
router.post("/:importID/revert", RequirePermissions("delete_score"), async (req, res) => {
const auth = req[SYMBOL_TACHI_API_AUTH];
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 } };
await ACTION_DeleteImport(taker, { id: req.params.importID });
return res.status(200).json({
success: true,
description: `Reverted import.`,
body: {},
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 } };
await ACTION_DeleteImport(taker, { id: req.params.importID });
return res.status(200).json({
success: true,
description: `Reverted import.`,
body: {},
});
});
// Finding jobs is slightly harder than just doing a key lookup, because of retrying.
async function FindImportJob(importID: string) {
@@ -1,7 +1,7 @@
import { ACTION_CustomiseScore } from "#actions/customise-score";
import { ACTION_DeleteScore } from "#actions/delete-score.js";
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
import { GetChartsBySongPgId } from "#lib/db-formats/chart";
import { GetChartsBySongId } from "#lib/db-formats/chart";
import { GetSongByLegacyID } from "#lib/db-formats/song";
import { log } from "#lib/log/log";
import { RequirePermissions } from "#server/middleware/auth";
@@ -86,11 +86,7 @@ router.get("/", async (req, res) => {
const charts =
songRes === undefined
? []
: await GetChartsBySongPgId(
toPgGame(score.game, score.playtype),
songRes.pgId,
score.songID,
);
: await GetChartsBySongId(toPgGame(score.game, score.playtype), songRes.newSongID);
const chart = charts.find((c) => c.chartID === score.chartID);
const song = songRes?.doc;
@@ -36,13 +36,13 @@ describe("GET /api/v1/search/chart-hash", () => {
const n = ++seedCounter;
const md5 = `a${n.toString(16).padStart(31, "0")}`.slice(0, 32);
const sha256 = "b".repeat(64);
const songPgId = `s_ch_hash_bms_${n}`;
const songNewID = `s_ch_hash_bms_${n}`;
const chartPgId = `c_ch_hash_bms_${n}`;
const legacySongId = 970_000 + n;
await DB.insertInto("song")
.values({
id: songPgId,
id: songNewID,
legacy_id: legacySongId,
game_group: "bms",
title: "Chart Hash Search BMS",
@@ -64,7 +64,7 @@ describe("GET /api/v1/search/chart-hash", () => {
id: chartPgId,
legacy_id: chartPgId,
game: "bms-7k",
song_id: songPgId,
song_id: songNewID,
level: "?",
level_num: 0,
is_primary: true,
@@ -95,13 +95,13 @@ describe("GET /api/v1/search/chart-hash", () => {
const n = ++seedCounter;
const md5 = "c".repeat(32);
const sha256 = `d${n.toString(16).padStart(63, "0")}`.slice(0, 64);
const songPgId = `s_ch_hash_pms_${n}`;
const songNewID = `s_ch_hash_pms_${n}`;
const chartPgId = `c_ch_hash_pms_${n}`;
const legacySongId = 971_000 + n;
await DB.insertInto("song")
.values({
id: songPgId,
id: songNewID,
legacy_id: legacySongId,
game_group: "pms",
title: "Chart Hash Search PMS",
@@ -123,7 +123,7 @@ describe("GET /api/v1/search/chart-hash", () => {
id: chartPgId,
legacy_id: chartPgId,
game: "pms-keyboard",
song_id: songPgId,
song_id: songNewID,
level: "?",
level_num: 0,
is_primary: true,
@@ -151,13 +151,13 @@ describe("GET /api/v1/search/chart-hash", () => {
it("returns ITG charts matched by hashGSv3", async () => {
const n = ++seedCounter;
const gsv3 = `itg-gsv3-${n}`;
const songPgId = `s_ch_hash_itg_${n}`;
const songNewID = `s_ch_hash_itg_${n}`;
const chartPgId = `c_ch_hash_itg_${n}`;
const legacySongId = 972_000 + n;
await DB.insertInto("song")
.values({
id: songPgId,
id: songNewID,
legacy_id: legacySongId,
game_group: "itg",
title: "Chart Hash Search ITG",
@@ -174,7 +174,7 @@ describe("GET /api/v1/search/chart-hash", () => {
id: chartPgId,
legacy_id: chartPgId,
game: "itg-stamina",
song_id: songPgId,
song_id: songNewID,
level: "10",
level_num: 10,
is_primary: true,
@@ -211,13 +211,13 @@ describe("GET /api/v1/search/chart-hash", () => {
it("returns USC charts matched by hashSHA1", async () => {
const n = ++seedCounter;
const sha1 = `e${n.toString(16).padStart(39, "0")}`.slice(0, 40);
const songPgId = `s_ch_hash_usc_${n}`;
const songNewID = `s_ch_hash_usc_${n}`;
const chartPgId = `c_ch_hash_usc_${n}`;
const legacySongId = 973_000 + n;
await DB.insertInto("song")
.values({
id: songPgId,
id: songNewID,
legacy_id: legacySongId,
game_group: "usc",
title: "Chart Hash Search USC",
@@ -234,7 +234,7 @@ describe("GET /api/v1/search/chart-hash", () => {
id: chartPgId,
legacy_id: chartPgId,
game: "usc-controller",
song_id: songPgId,
song_id: songNewID,
level: "10",
level_num: 10,
is_primary: true,
@@ -11,17 +11,17 @@ afterAll(() => CloseServerConnection());
let seedCounter = 0;
async function seedBmsChartWithHashes(opts: {
sha256: string;
game: "bms-7k" | "bms-14k";
md5: string;
sha256: string;
}) {
const n = ++seedCounter;
const songPgId = `s_bms_router_${n}`;
const songNewID = `s_bms_router_${n}`;
const chartPgId = `c_bms_router_${n}`;
await DB.insertInto("song")
.values({
id: songPgId,
id: songNewID,
legacy_id: 960_000 + n,
game_group: "bms",
title: "BMS Router Test",
@@ -43,7 +43,7 @@ async function seedBmsChartWithHashes(opts: {
id: chartPgId,
legacy_id: chartPgId,
game: opts.game,
song_id: songPgId,
song_id: songNewID,
level: "?",
level_num: 0,
is_primary: true,
@@ -61,7 +61,7 @@ async function seedBmsChartWithHashes(opts: {
})
.execute();
return { chartPgId, songPgId };
return { chartPgId, songNewID };
}
async function insertBmsPb(userId: number, chartPgId: string) {
@@ -0,0 +1,244 @@
import { rebuildFolderChartLookup } from "#lib/folders/rebuild-folder-chart-lookup.js";
import { mongoScoreDataToPg } from "#lib/v3/migration-tools";
import DB from "#services/pg/db";
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
import { seedUser } from "#test-utils/pg-fixtures";
import { TestingIIDXSPScore } from "#test-utils/test-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import deepmerge from "deepmerge";
import { type MONGO_ScoreData } from "tachi-common";
import { afterAll, describe, expect, it } from "vitest";
afterAll(() => CloseServerConnection());
let seedCounter = 0;
async function seedIidxSpProfile(userId: number) {
await DB.insertInto("game_profile")
.values({
user_id: userId,
game: "iidx-sp",
ratings: JSON.stringify({}),
classes: JSON.stringify({}),
})
.execute();
}
async function seedFolderWithThreeCharts(opts: { folderId: string; folderLegacy: string }) {
const n = ++seedCounter;
const songId = `S_FL_TL_${n}`;
const chartA = `C_FL_TL_A_${n}`;
const chartB = `C_FL_TL_B_${n}`;
const chartC = `C_FL_TL_C_${n}`;
await DB.insertInto("song")
.values({
id: songId,
legacy_id: 91_000 + n,
game_group: "iidx",
title: "Folder TL",
artist: "T",
search_terms: [],
alt_titles: [],
data: JSON.stringify({}),
fts_document: "",
})
.execute();
await DB.insertInto("chart")
.values([
{
id: chartA,
legacy_id: chartA,
game: "iidx-sp",
song_id: songId,
level: "10",
level_num: 10,
is_primary: true,
difficulty: "ANOTHER",
versions: [],
data: JSON.stringify({}),
},
{
id: chartB,
legacy_id: chartB,
game: "iidx-sp",
song_id: songId,
level: "10",
level_num: 10,
is_primary: true,
difficulty: "HYPER",
versions: [],
data: JSON.stringify({}),
},
{
id: chartC,
legacy_id: chartC,
game: "iidx-sp",
song_id: songId,
level: "10",
level_num: 10,
is_primary: true,
difficulty: "NORMAL",
versions: [],
data: JSON.stringify({}),
},
])
.execute();
const where = `chart.id IN ('${chartA}', '${chartB}', '${chartC}')`;
await DB.insertInto("folder")
.values({
id: opts.folderId,
legacy_id: opts.folderLegacy,
game: "iidx-sp",
inactive: false,
title: "TL folder",
slug: null,
where,
version_filter: null,
search_terms: [],
})
.execute();
await rebuildFolderChartLookup(DB, { folderId: opts.folderId });
return { chartA, chartB, chartC };
}
async function insertIidxSpScore(opts: {
chartId: string;
id: string;
scoreData: MONGO_ScoreData<"iidx:SP">;
timeAchievedMs: number | null;
userId: number;
}) {
const { data, derived, judgements } = mongoScoreDataToPg("iidx:SP", opts.scoreData);
const now = new Date().toISOString();
await DB.insertInto("score")
.values({
id: opts.id,
user_id: opts.userId,
chart_id: opts.chartId,
game: "iidx-sp",
session_id: null,
import_id: null,
data: JSON.stringify(data),
derived_data: JSON.stringify(derived),
judgements: JSON.stringify(judgements),
calculated_data: JSON.stringify(TestingIIDXSPScore.calculatedData),
meta: JSON.stringify({}),
time_achieved:
opts.timeAchievedMs !== null
? UnixMillisecondsToISO8601(opts.timeAchievedMs)
: null,
time_added: now,
highlight: false,
comment: null,
})
.execute();
}
describe("GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/timeline", () => {
it("returns one earliest qualifying score per chart, then sorted by time (null as 0)", async () => {
const { id: userId } = await seedUser({ username: `folder_tl_${Date.now()}` });
await seedIidxSpProfile(userId);
const folderId = `folder_tl_${Date.now()}`;
const { chartA, chartB, chartC } = await seedFolderWithThreeCharts({
folderId,
folderLegacy: `${folderId}_leg`,
});
const baseSd = TestingIIDXSPScore.scoreData;
// Chart A: CLEAR lamp, null time — sorts first in final array (null → 0).
await insertIidxSpScore({
id: "tl_s_a",
userId,
chartId: chartA,
scoreData: baseSd,
timeAchievedMs: null,
});
// Chart B: multiple rows meeting lamp >= CLEAR; earliest play time wins (100ms).
await insertIidxSpScore({
id: "tl_s_b_500",
userId,
chartId: chartB,
scoreData: baseSd,
timeAchievedMs: 500,
});
await insertIidxSpScore({
id: "tl_s_b_100",
userId,
chartId: chartB,
scoreData: baseSd,
timeAchievedMs: 100,
});
// EASY CLEAR (index 3) — does not satisfy CLEAR floor (index 4).
await insertIidxSpScore({
id: "tl_s_b_easy",
userId,
chartId: chartB,
scoreData: deepmerge(baseSd, {
lamp: "EASY CLEAR",
enumIndexes: { lamp: 3, grade: baseSd.enumIndexes?.grade ?? 3 },
}) as MONGO_ScoreData<"iidx:SP">,
timeAchievedMs: 50,
});
await insertIidxSpScore({
id: "tl_s_b_null",
userId,
chartId: chartB,
scoreData: baseSd,
timeAchievedMs: null,
});
// Chart C
await insertIidxSpScore({
id: "tl_s_c",
userId,
chartId: chartC,
scoreData: baseSd,
timeAchievedMs: 200,
});
const res = await mockApi.get(
`/api/v1/users/${userId}/games/iidx/SP/folders/${folderId}/timeline?criteriaType=lamp&criteriaValue=CLEAR`,
);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
const ids = res.body.body.scores.map((s: { scoreID: string }) => s.scoreID);
expect(ids).toEqual(["tl_s_a", "tl_s_b_100", "tl_s_c"]);
});
it("returns 400 for invalid criteriaType", async () => {
const { id: userId } = await seedUser({ username: `folder_tl_bad_${Date.now()}` });
await seedIidxSpProfile(userId);
const folderId = `folder_tl_bad_${Date.now()}`;
await seedFolderWithThreeCharts({ folderId, folderLegacy: `${folderId}_leg` });
const res = await mockApi.get(
`/api/v1/users/${userId}/games/iidx/SP/folders/${folderId}/timeline?criteriaType=not_a_metric&criteriaValue=CLEAR`,
);
expect(res.status).toBe(400);
});
it("returns 400 for invalid criteriaValue for an ENUM metric", async () => {
const { id: userId } = await seedUser({ username: `folder_tl_badval_${Date.now()}` });
await seedIidxSpProfile(userId);
const folderId = `folder_tl_badval_${Date.now()}`;
await seedFolderWithThreeCharts({ folderId, folderLegacy: `${folderId}_leg` });
const res = await mockApi.get(
`/api/v1/users/${userId}/games/iidx/SP/folders/${folderId}/timeline?criteriaType=lamp&criteriaValue=nope`,
);
expect(res.status).toBe(400);
});
});
@@ -1,19 +1,12 @@
import type { FilterQuery } from "mongodb";
import prValidate from "#server/middleware/prudence-validate";
import MONGODB_KILL from "#services/mongo/db";
import DB from "#services/pg/db";
import { GetEnumDistForFolder, GetFolderChartsAndSongs, GetPBsOnFolder } from "#utils/folder";
import { GetFolderTimelineScores } from "#utils/queries/scores";
import { GetTachiData, GetUGPT } from "#utils/req-tachi-data";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import { Router } from "express";
import { sql } from "kysely";
import {
GetGamePTConfig,
GetScoreMetricConf,
type MONGO_ScoreDocument,
ValidateMetric,
} from "tachi-common";
import { GetGamePTConfig, GetScoreMetricConf, ValidateMetric } from "tachi-common";
import { GetFolderFromParam } from "../../../../../../../games/_game/_playtype/folders/middleware";
import { RequireSelfRequestFromUser } from "../../../../../middleware";
@@ -153,45 +146,14 @@ router.get(
});
}
const matchCriteria: FilterQuery<MONGO_ScoreDocument> = {
userID: user.id,
const scores = await GetFolderTimelineScores(
user.id,
game,
playtype,
chartID: { $in: charts.map((e) => e.chartID) },
};
matchCriteria[`scoreData.enumIndexes.${metric}`] = { $gte: criteriaValue };
// Returns a unique score per-chart that was the first score to achieve
// this criteria on that chart.
const scoresAgg: Array<{ doc: MONGO_ScoreDocument }> = await MONGODB_KILL.scores.aggregate([
{
$match: matchCriteria,
},
{
$addFields: {
__sortTime: { $ifNull: ["$timeAchieved", Infinity, "$timeAchieved"] },
},
},
{
$sort: {
__sortTime: 1,
},
},
{
$group: {
_id: "$chartID",
doc: { $first: "$$ROOT" },
},
},
{
$unset: ["doc.__sortTime"],
},
]);
const scores = scoresAgg
.map((e) => e.doc)
.sort((a, b) => (a.timeAchieved ?? 0) - (b.timeAchieved ?? 0));
charts.map((e) => e.chartID),
metric,
criteriaValue,
);
return res.status(200).json({
success: true,
@@ -1,17 +1,18 @@
import { ACTION_SetRivals } from "#actions/set-rivals";
import { CreateActivityRouteHandler } from "#lib/activity/activity";
import { SetRivalsFailReasons } from "#lib/constants/err-codes";
import { GetChallengerUsers, GetRivalIDs, GetRivalUsers, SetRivals } from "#lib/rivals/rivals";
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
import { LoadPbDocumentsForUserSetSortedByCalculatedAlg } from "#lib/db-formats/pb";
import { GetChallengerUsers, GetRivalIDs, GetRivalUsers } from "#lib/rivals/rivals";
import { RequirePermissions } from "#server/middleware/auth";
import prValidate from "#server/middleware/prudence-validate";
import MONGODB_KILL from "#services/mongo/db";
import { GetRelevantSongsAndCharts } from "#utils/db";
import { IsString } from "#utils/misc";
import { GetUGPT } from "#utils/req-tachi-data";
import { CheckStrScoreAlg } from "#utils/string-checks";
import { GetUsersWithIDs } from "#utils/user";
import { GetUsersWithIDs, GetUserWithIDGuaranteed } from "#utils/user";
import { Router } from "express";
import { p } from "prudence";
import { FormatGameGroup, GetGamePTConfig, type integer } from "tachi-common";
import { GamePTToV3, GetGamePTConfig, type integer } from "tachi-common";
import { RequireAuthedAsUser } from "../../../../middleware";
@@ -56,28 +57,25 @@ router.put(
const rivalIDs = body.rivalIDs;
const { user, game, playtype } = GetUGPT(req);
const result = await SetRivals(user.id, game, playtype, rivalIDs);
const authUserID = req[SYMBOL_TACHI_API_AUTH].userID;
if (result === SetRivalsFailReasons.RIVALED_SELF) {
return res.status(400).json({
if (authUserID === null) {
return res.status(401).json({
success: false,
description: `You cannot rival yourself.`,
});
} else if (result === SetRivalsFailReasons.RIVALS_HAVENT_PLAYED_GPT) {
return res.status(400).json({
success: false,
description: `Not all of the rivals you specified have played ${FormatGameGroup(
game,
playtype,
)}.`,
});
} else if (result === SetRivalsFailReasons.TOO_MANY) {
return res.status(400).json({
success: false,
description: `You can't set more than 5 rivals.`,
description: `Authentication is required for this endpoint.`,
});
}
const authedUser = await GetUserWithIDGuaranteed(authUserID);
const taker = { ip: req.ip, acct: { id: authedUser.id, username: authedUser.username } };
await ACTION_SetRivals(taker, {
userID: user.id,
game,
playtype,
rivalIDs,
});
return res.status(200).json({
success: true,
description: `Set ${rivalIDs.length} rivals.`,
@@ -138,19 +136,8 @@ router.get("/pb-leaderboard", async (req, res) => {
const rivalIDs = await GetRivalIDs(user.id, game, playtype);
const userSet = [...rivalIDs, user.id];
const pbs = await MONGODB_KILL["personal-bests"].find(
{
game,
playtype,
userID: { $in: userSet },
},
{
sort: {
[`calculatedData.${alg}`]: -1,
},
limit: 100,
},
);
const v3Game = GamePTToV3(game, playtype);
const pbs = await LoadPbDocumentsForUserSetSortedByCalculatedAlg(userSet, v3Game, alg, 100);
const users = await GetUsersWithIDs(pbs.map((e) => e.userID));
@@ -1,21 +1,22 @@
import { SubscribeFailReasons } from "#lib/constants/err-codes";
import { log } from "#lib/log/log";
import { ServerConfig } from "#lib/setup/config";
import type { Game } from "tachi-db";
import { ACTION_AddGoal } from "#actions/add-goal";
import { ACTION_RemoveGoalSubscription } from "#actions/remove-goal-subscription";
import {
ConstructGoal,
GetQuestsThatContainGoal,
SubscribeToGoal,
UnsubscribeFromGoal,
} from "#lib/targets/goals";
ToGoalDocument,
ToGoalSubscriptionDocument,
ToQuestSubscriptionDocument,
} from "#lib/db-formats/target-documents";
import { GetQuestsThatContainGoal } from "#lib/targets/goals";
import { GetParentQuests } from "#lib/targets/quests";
import { RequirePermissions } from "#server/middleware/auth";
import prValidate from "#server/middleware/prudence-validate";
import MONGODB_KILL from "#services/mongo/db";
import { GetGoalForIDGuaranteed } from "#utils/db";
import DB from "#services/pg/db";
import { GetGoalForIDGuaranteed, GetGoalSubscriptionForIDGuaranteed } from "#utils/db";
import { AssignToReqTachiData, GetTachiData, GetUGPT } from "#utils/req-tachi-data";
import { type RequestHandler, Router } from "express";
import { p } from "prudence";
import { GetGamePTConfig, type MONGO_GoalDocument, type MONGO_QuestDocument } from "tachi-common";
import { GamePTToV3, type MONGO_GoalDocument, type MONGO_QuestDocument } from "tachi-common";
import { RequireAuthedAsUser } from "../../../../../middleware";
@@ -29,23 +30,57 @@ const router: Router = Router({ mergeParams: true });
router.get("/", async (req, res) => {
const { user, game, playtype } = GetUGPT(req);
const goalSubs = await MONGODB_KILL["goal-subs"].find({
userID: user.id,
game,
playtype,
});
const v3Game = GamePTToV3(game, playtype);
const goals = await MONGODB_KILL.goals.find({
goalID: { $in: goalSubs.map((e) => e.goalID) },
});
const subRows = await DB.selectFrom("goal_sub")
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
.selectAll("goal_sub")
.select("goal.game as goal_game")
.where("goal_sub.user_id", "=", user.id)
.where("goal.game", "=", v3Game)
.execute();
const goalSubs = subRows.map((r) =>
ToGoalSubscriptionDocument({
...r,
goal_game: r.goal_game as Game,
}),
);
const goalIds = goalSubs.map((e) => e.goalID);
const goalRows =
goalIds.length === 0
? []
: await DB.selectFrom("goal").selectAll().where("goal.id", "in", goalIds).execute();
const goals = goalRows.map(ToGoalDocument);
const allQuests = await GetParentQuests(user.id, game, playtype, goalSubs);
const questSubs = await MONGODB_KILL["quest-subs"].find({ userID: user.id, game, playtype });
const questSubRows = await DB.selectFrom("quest_sub")
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
.selectAll("quest_sub")
.select("quest.game as quest_game")
.where("quest_sub.user_id", "=", user.id)
.where("quest.game", "=", v3Game)
.execute();
const questSubs = questSubRows.map((r) =>
ToQuestSubscriptionDocument({
quest_id: r.quest_id,
user_id: r.user_id,
progress: r.progress,
last_interaction: r.last_interaction,
achieved: r.achieved,
time_achieved: r.time_achieved,
was_instantly_achieved: r.was_instantly_achieved,
quest_game: r.quest_game as Game,
}),
);
const questSubIDs = questSubs.map((e) => e.questID);
// filter parent quests to only those that this user is subscribed to
const quests = allQuests.filter((quest) => questSubIDs.includes(quest.questID));
return res.status(200).json({
@@ -136,67 +171,29 @@ router.post(
async (req, res) => {
const { user, game, playtype } = GetUGPT(req);
const existingGoalsCount = await MONGODB_KILL["goal-subs"].count({
userID: user.id,
game,
playtype,
});
const sessionUser = req.session.tachi?.user;
if (existingGoalsCount > ServerConfig.MAX_GOAL_SUBSCRIPTIONS) {
return res.status(400).json({
if (!sessionUser) {
return res.status(401).json({
success: false,
description: `You already have ${ServerConfig.MAX_GOAL_SUBSCRIPTIONS} goals. You cannot have anymore.`,
});
}
const gptConfig = GetGamePTConfig(game, playtype);
const validCriteria = [
...Object.keys(gptConfig.providedMetrics),
...Object.keys(gptConfig.derivedMetrics),
];
if (!validCriteria.includes(req.body.criteria.key)) {
return res.status(400).json({
success: false,
description: `Invalid criteria '${
req.body.criteria.key
}', expected any of ${validCriteria.join(", ")}.`,
description: "You are not authenticated.",
});
}
const data = req.safeBody as GoalCreationBody;
let goal;
const taker = { ip: req.ip, acct: { id: sessionUser.id, username: sessionUser.username } };
try {
goal = await ConstructGoal(data.charts, data.criteria, game, playtype);
} catch (e) {
const err = e as Error;
const { goalID } = await ACTION_AddGoal(taker, {
userID: user.id,
game,
playtype,
charts: data.charts,
criteria: data.criteria,
});
log.info({ err }, err.message);
return res.status(400).json({
success: false,
description: err.message,
});
}
const goalSub = await SubscribeToGoal(user.id, goal, true);
if (goalSub === SubscribeFailReasons.ALREADY_SUBSCRIBED) {
return res.status(409).json({
success: false,
description: `You are already subscribed to this goal.`,
});
}
if (goalSub === SubscribeFailReasons.ALREADY_ACHIEVED) {
return res.status(400).json({
success: false,
description: `You can't directly assign goals that you would immediately achieve.`,
});
}
const goal = await GetGoalForIDGuaranteed(goalID);
const goalSub = await GetGoalSubscriptionForIDGuaranteed(goalID, user.id);
return res.status(200).json({
success: true,
@@ -212,20 +209,29 @@ router.post(
const GetGoalSubscription: RequestHandler = async (req, res, next) => {
const { user, game, playtype } = GetUGPT(req);
const goalSub = await MONGODB_KILL["goal-subs"].findOne({
userID: user.id,
game,
playtype,
goalID: req.params.goalID,
});
const v3Game = GamePTToV3(game, playtype);
if (!goalSub) {
const row = await DB.selectFrom("goal_sub")
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
.selectAll("goal_sub")
.select("goal.game as goal_game")
.where("goal_sub.user_id", "=", user.id)
.where("goal_sub.goal_id", "=", req.params.goalID)
.where("goal.game", "=", v3Game)
.executeTakeFirst();
if (!row) {
return res.status(404).json({
success: false,
description: `${user.username} is not subscribed to this goal.`,
});
}
const goalSub = ToGoalSubscriptionDocument({
...row,
goal_game: row.goal_game as Game,
});
AssignToReqTachiData(req, { goalSubDoc: goalSub });
next();
@@ -265,32 +271,28 @@ router.get("/:goalID", GetGoalSubscription, async (req, res) => {
router.delete(
"/:goalID",
RequireAuthedAsUser,
GetGoalSubscription,
RequirePermissions("manage_targets"),
async (req, res) => {
const goalSub = GetTachiData(req, "goalSubDoc");
const { user, game, playtype } = GetUGPT(req);
const fail = await UnsubscribeFromGoal(goalSub, false);
const sessionUser = req.session.tachi?.user;
if (fail) {
switch (fail.reason) {
case "WAS_STANDALONE":
// can't happen. mightaswell handle it though.
return res.status(400).json({
success: false,
description: `This goal was assigned by you and can't be removed as a consequence of another action.`,
});
case "HAS_QUEST_DEPENDENCIES":
return res.status(400).json({
success: false,
description: `This goal is part of a quest you are subscribed to. It can only be removed by unsubscribing from the relevant quests: ${fail.parentQuests
.map((e) => `'${e.quest.name}'`)
.join(", ")}.`,
});
}
if (!sessionUser) {
return res.status(401).json({
success: false,
description: "You are not authenticated.",
});
}
const taker = { ip: req.ip, acct: { id: sessionUser.id, username: sessionUser.username } };
await ACTION_RemoveGoalSubscription(taker, {
userID: user.id,
game,
playtype,
goalID: req.params.goalID,
});
return res.status(200).json({
success: true,
description: `Removed this goal from your subscriptions.`,
@@ -1,4 +1,5 @@
import { SubscribeFailReasons } from "#lib/constants/err-codes";
import { ToQuestDocument, ToQuestSubscriptionDocument } from "#lib/db-formats/target-documents";
import { log } from "#lib/log/log";
import { ServerConfig } from "#lib/setup/config";
import {
@@ -8,10 +9,13 @@ import {
UnsubscribeFromQuest,
} from "#lib/targets/quests";
import { RequirePermissions } from "#server/middleware/auth";
import MONGODB_KILL from "#services/mongo/db";
import DB from "#services/pg/db";
import { AssignToReqTachiData, GetGPT, GetTachiData, GetUGPT } from "#utils/req-tachi-data";
import { FormatUserDoc } from "#utils/user";
import { type RequestHandler, Router } from "express";
import { GamePTToV3 } from "tachi-common";
import type { Game } from "tachi-db";
import { sql } from "kysely";
import { RequireAuthedAsUser } from "../../../../../middleware";
@@ -25,23 +29,55 @@ const router: Router = Router({ mergeParams: true });
router.get("/", async (req, res) => {
const { user, game, playtype } = GetUGPT(req);
const questSubs = await MONGODB_KILL["quest-subs"].find({
userID: user.id,
game,
playtype,
});
const v3Game = GamePTToV3(game, playtype);
const quests = await MONGODB_KILL.quests.find({
questID: { $in: questSubs.map((e) => e.questID) },
});
const questSubRows = await DB.selectFrom("quest_sub")
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
.selectAll("quest_sub")
.select("quest.game as quest_game")
.where("quest_sub.user_id", "=", user.id)
.where("quest.game", "=", v3Game)
.execute();
if (quests.length !== questSubs.length) {
const questSubs = questSubRows.map((r) =>
ToQuestSubscriptionDocument({
quest_id: r.quest_id,
user_id: r.user_id,
progress: r.progress,
last_interaction: r.last_interaction,
achieved: r.achieved,
time_achieved: r.time_achieved,
was_instantly_achieved: r.was_instantly_achieved,
quest_game: r.quest_game as Game,
}),
);
const questIds = questSubs.map((e) => e.questID);
const questRows =
questIds.length === 0
? []
: await DB.selectFrom("quest").selectAll().where("quest.id", "in", questIds).execute();
if (questRows.length !== questSubs.length) {
log.error(
`Found ${questSubs.length} subscriptions, but got ${quests.length} parents. This is a state desync.`,
`Found ${questSubs.length} subscriptions, but got ${questRows.length} parents. This is a state desync.`,
);
throw new Error("Failed to fetch quests");
}
const questById = new Map(questRows.map((q) => [q.id, ToQuestDocument(q)]));
const quests = questIds.map((id) => {
const q = questById.get(id);
if (!q) {
throw new Error("Failed to fetch quests");
}
return q;
});
const goals = await GetGoalsInQuests(quests);
return res.status(200).json({
@@ -58,20 +94,35 @@ router.get("/", async (req, res) => {
const GetQuestSubscription: RequestHandler = async (req, res, next) => {
const { user, game, playtype } = GetUGPT(req);
const questSub = await MONGODB_KILL["quest-subs"].findOne({
userID: user.id,
game,
playtype,
questID: req.params.questID,
});
const v3Game = GamePTToV3(game, playtype);
if (!questSub) {
const row = await DB.selectFrom("quest_sub")
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
.selectAll("quest_sub")
.select("quest.game as quest_game")
.where("quest_sub.user_id", "=", user.id)
.where("quest_sub.quest_id", "=", req.params.questID)
.where("quest.game", "=", v3Game)
.executeTakeFirst();
if (!row) {
return res.status(404).json({
success: false,
description: `${user.username} is not subscribed to this quest.`,
});
}
const questSub = ToQuestSubscriptionDocument({
quest_id: row.quest_id,
user_id: row.user_id,
progress: row.progress,
last_interaction: row.last_interaction,
achieved: row.achieved,
time_achieved: row.time_achieved,
was_instantly_achieved: row.was_instantly_achieved,
quest_game: row.quest_game as Game,
});
AssignToReqTachiData(req, { questSubDoc: questSub });
next();
@@ -80,20 +131,22 @@ const GetQuestSubscription: RequestHandler = async (req, res, next) => {
const GetQuest: RequestHandler = async (req, res, next) => {
const { game, playtype } = GetGPT(req);
const quest = await MONGODB_KILL.quests.findOne({
game,
playtype,
questID: req.params.questID,
});
const v3Game = GamePTToV3(game, playtype);
if (!quest) {
const row = await DB.selectFrom("quest")
.selectAll()
.where("quest.id", "=", req.params.questID)
.where("quest.game", "=", v3Game)
.executeTakeFirst();
if (!row) {
return res.status(404).json({
success: false,
description: `Can't find a quest with id '${req.params.questID}'.`,
});
}
AssignToReqTachiData(req, { questDoc: quest });
AssignToReqTachiData(req, { questDoc: ToQuestDocument(row) });
next();
};
@@ -110,9 +163,6 @@ router.get("/:questID", GetQuest, GetQuestSubscription, async (req, res) => {
const questSub = GetTachiData(req, "questSubDoc");
const quest = GetTachiData(req, "questDoc");
// Evaluate each goal for the user. This operation is much faster if the user is
// subscribed to the quest (they are), as we can just read their goalSub
// for each goal.
const { goalResults: results, goals } = await EvaluateQuestProgress(user.id, quest);
return res.status(200).json({
@@ -142,11 +192,16 @@ router.put(
async (req, res) => {
const { user, game, playtype } = GetUGPT(req);
const existingQuestsCount = await MONGODB_KILL["quest-subs"].count({
userID: user.id,
game,
playtype,
});
const v3Game = GamePTToV3(game, playtype);
const countRow = await DB.selectFrom("quest_sub")
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
.select(sql<number>`count(*)::int`.as("c"))
.where("quest_sub.user_id", "=", user.id)
.where("quest.game", "=", v3Game)
.executeTakeFirst();
const existingQuestsCount = Number(countRow?.c ?? 0);
if (existingQuestsCount > ServerConfig.MAX_QUEST_SUBSCRIPTIONS) {
return res.status(400).json({
@@ -157,12 +212,15 @@ router.put(
const quest = GetTachiData(req, "questDoc");
const alreadySubscibed = await MONGODB_KILL["quest-subs"].findOne({
userID: user.id,
questID: quest.questID,
});
const alreadySubscribed = await DB.selectFrom("quest_sub")
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
.select("quest_sub.quest_id")
.where("quest_sub.user_id", "=", user.id)
.where("quest_sub.quest_id", "=", quest.questID)
.where("quest.game", "=", v3Game)
.executeTakeFirst();
if (alreadySubscibed) {
if (alreadySubscribed) {
return res.status(409).json({
success: false,
description: `You are already subscribed to this goal.`,
@@ -171,16 +229,6 @@ router.put(
const subResult = await SubscribeToQuest(user.id, quest, false);
// Users should be able to subscribe to quests EVEN IF they would instantly
// achieve them.
// if (subResult === SubscribeFailReasons.ALREADY_ACHIEVED) {
// return res.status(400).json({
// success: false,
// description: `You cannot assign a quest that would be immediately achieved.`,
// });
// }
if (subResult === SubscribeFailReasons.ALREADY_SUBSCRIBED) {
return res.status(409).json({
success: false,
@@ -207,7 +255,7 @@ router.delete(
GetQuest,
RequirePermissions("manage_targets"),
async (req, res) => {
const { user } = GetUGPT(req);
const { user, game, playtype } = GetUGPT(req);
const quest = GetTachiData(req, "questDoc");
log.info(
@@ -218,18 +266,35 @@ router.delete(
`User ${FormatUserDoc(user)} is unsubscribing from quest '${quest.name}'.`,
);
const questSub = await MONGODB_KILL["quest-subs"].findOne({
userID: user.id,
questID: quest.questID,
});
const v3Game = GamePTToV3(game, playtype);
if (!questSub) {
const row = await DB.selectFrom("quest_sub")
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
.selectAll("quest_sub")
.select("quest.game as quest_game")
.where("quest_sub.user_id", "=", user.id)
.where("quest_sub.quest_id", "=", quest.questID)
.where("quest.game", "=", v3Game)
.executeTakeFirst();
if (!row) {
return res.status(409).json({
success: false,
description: `Can't unsubscribe from a quest you were never subscribed to.`,
});
}
const questSub = ToQuestSubscriptionDocument({
quest_id: row.quest_id,
user_id: row.user_id,
progress: row.progress,
last_interaction: row.last_interaction,
achieved: row.achieved,
time_achieved: row.time_achieved,
was_instantly_achieved: row.was_instantly_achieved,
quest_game: row.quest_game as Game,
});
await UnsubscribeFromQuest(questSub, quest);
return res.status(200).json({
@@ -1,8 +1,14 @@
import { GetChartById } from "#lib/db-formats/chart";
import { LoadFolderDocumentById } from "#lib/db-formats/folders";
import {
ToGoalDocument,
ToGoalSubscriptionDocument,
ToQuestSubscriptionDocument,
} from "#lib/db-formats/target-documents";
import { log } from "#lib/log/log";
import { GetRelevantGoals } from "#lib/targets/goals";
import { GetParentQuests } from "#lib/targets/quests";
import MONGODB_KILL from "#services/mongo/db";
import DB from "#services/pg/db";
import {
GetRecentlyAchievedGoals,
GetRecentlyAchievedQuests,
@@ -13,6 +19,7 @@ import { GetFolderChartIDs } from "#utils/folder";
import { GetUGPT } from "#utils/req-tachi-data";
import { Router } from "express";
import { GamePTToV3, MongoChartLegacyId } from "tachi-common";
import type { Game } from "tachi-db";
import goalsRouter from "./goals/router";
import questsRouter from "./quests/router";
@@ -107,9 +114,33 @@ router.get("/on-chart/:chartID", async (req, res) => {
const quests = await GetParentQuests(user.id, game, playtype, goalSubs);
const questSubs = await MONGODB_KILL["quest-subs"].find({
questID: { $in: quests.map((e) => e.questID) },
});
const v3Game = GamePTToV3(game, playtype);
const questIds = quests.map((e) => e.questID);
const questSubRows =
questIds.length === 0
? []
: await DB.selectFrom("quest_sub")
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
.selectAll("quest_sub")
.select("quest.game as quest_game")
.where("quest_sub.user_id", "=", user.id)
.where("quest.game", "=", v3Game)
.where("quest_sub.quest_id", "in", questIds)
.execute();
const questSubs = questSubRows.map((r) =>
ToQuestSubscriptionDocument({
quest_id: r.quest_id,
user_id: r.user_id,
progress: r.progress,
last_interaction: r.last_interaction,
achieved: r.achieved,
time_achieved: r.time_achieved,
was_instantly_achieved: r.was_instantly_achieved,
quest_game: r.quest_game as Game,
}),
);
return res.status(200).json({
success: true,
@@ -133,9 +164,9 @@ router.get("/on-folder/:folderID", async (req, res) => {
const folderID = req.params.folderID;
const folder = await MONGODB_KILL.folders.findOne({ folderID, playtype });
const folder = await LoadFolderDocumentById(folderID);
if (!folder) {
if (!folder || folder.game !== game || folder.playtype !== playtype) {
return res.status(404).json({
success: false,
description: `Failed to find a folder with folderID '${folderID}'.`,
@@ -144,42 +175,78 @@ router.get("/on-folder/:folderID", async (req, res) => {
const folderChartIDs = await GetFolderChartIDs(folderID);
const allGoalSubs = await MONGODB_KILL["goal-subs"].find({
userID: user.id,
game,
playtype,
});
const v3Game = GamePTToV3(game, playtype);
const allSubRows = await DB.selectFrom("goal_sub")
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
.selectAll("goal_sub")
.select("goal.game as goal_game")
.where("goal_sub.user_id", "=", user.id)
.where("goal.game", "=", v3Game)
.execute();
const allGoalSubs = allSubRows.map((r) =>
ToGoalSubscriptionDocument({
...r,
goal_game: r.goal_game as Game,
}),
);
const goalIDs = allGoalSubs.map((e) => e.goalID);
// goals are relevant to a folder if it's part of a folder type goal
// or if it's on any of the charts in the folder.
// this is convenient for the UI, atleast.
const goals = await Promise.all([
MONGODB_KILL.goals.find({
"charts.type": { $in: ["single", "multi"] },
"charts.data": { $in: folderChartIDs },
goalID: { $in: goalIDs },
}),
MONGODB_KILL.goals.find({
"charts.type": "folder",
"charts.data": folderID,
goalID: { $in: goalIDs },
}),
]).then((r) => r.flat());
const goalDocRows =
goalIDs.length === 0
? []
: await DB.selectFrom("goal").selectAll().where("goal.id", "in", goalIDs).execute();
const goalSubs = await MONGODB_KILL["goal-subs"].find({
goalID: { $in: goals.map((e) => e.goalID) },
userID: user.id,
game,
playtype,
});
const goals: Array<ReturnType<typeof ToGoalDocument>> = [];
for (const row of goalDocRows) {
const g = ToGoalDocument(row);
if (g.charts.type === "single" && folderChartIDs.includes(g.charts.data)) {
goals.push(g);
} else if (
g.charts.type === "multi" &&
g.charts.data.some((c: string) => folderChartIDs.includes(c))
) {
goals.push(g);
} else if (g.charts.type === "folder" && g.charts.data === folderID) {
goals.push(g);
}
}
const active = new Set(goals.map((g) => g.goalID));
const goalSubs = allGoalSubs.filter((s) => active.has(s.goalID));
const quests = await GetParentQuests(user.id, game, playtype, goalSubs);
const questSubs = await MONGODB_KILL["quest-subs"].find({
questID: { $in: quests.map((e) => e.questID) },
});
const questIds = quests.map((e) => e.questID);
const questSubRows =
questIds.length === 0
? []
: await DB.selectFrom("quest_sub")
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
.selectAll("quest_sub")
.select("quest.game as quest_game")
.where("quest_sub.user_id", "=", user.id)
.where("quest.game", "=", v3Game)
.where("quest_sub.quest_id", "in", questIds)
.execute();
const questSubs = questSubRows.map((r) =>
ToQuestSubscriptionDocument({
quest_id: r.quest_id,
user_id: r.user_id,
progress: r.progress,
last_interaction: r.last_interaction,
achieved: r.achieved,
time_achieved: r.time_achieved,
was_instantly_achieved: r.was_instantly_achieved,
quest_game: r.quest_game as Game,
}),
);
return res.status(200).json({
success: true,
@@ -201,11 +268,45 @@ router.get("/on-folder/:folderID", async (req, res) => {
router.get("/all-subs", async (req, res) => {
const { user, game, playtype } = GetUGPT(req);
const [goalSubs, questSubs] = await Promise.all([
MONGODB_KILL["goal-subs"].find({ userID: user.id, game, playtype }),
MONGODB_KILL["quest-subs"].find({ userID: user.id, game, playtype }),
const v3Game = GamePTToV3(game, playtype);
const [goalSubRows, questSubRows] = await Promise.all([
DB.selectFrom("goal_sub")
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
.selectAll("goal_sub")
.select("goal.game as goal_game")
.where("goal_sub.user_id", "=", user.id)
.where("goal.game", "=", v3Game)
.execute(),
DB.selectFrom("quest_sub")
.innerJoin("quest", "quest.id", "quest_sub.quest_id")
.selectAll("quest_sub")
.select("quest.game as quest_game")
.where("quest_sub.user_id", "=", user.id)
.where("quest.game", "=", v3Game)
.execute(),
]);
const goalSubs = goalSubRows.map((r) =>
ToGoalSubscriptionDocument({
...r,
goal_game: r.goal_game as Game,
}),
);
const questSubs = questSubRows.map((r) =>
ToQuestSubscriptionDocument({
quest_id: r.quest_id,
user_id: r.user_id,
progress: r.progress,
last_interaction: r.last_interaction,
achieved: r.achieved,
time_achieved: r.time_achieved,
was_instantly_achieved: r.was_instantly_achieved,
quest_game: r.quest_game as Game,
}),
);
return res.status(200).json({
success: true,
description: `Returned all target subscriptions.`,
@@ -1,18 +1,7 @@
/**
* IR KsHook SV6C integration tests.
*
* Score import still resolves SDVX charts/songs via Mongo-backed queries until that path is
* migrated to Postgres. Tests that require a successful chart lookup are marked with `it.fails`
* until the migration is done.
*
* Vitest truncates Postgres per test. ResetDBState() is currently a no-op.
*/
import { seedApiToken } from "#actions/test-utils/api-tokens";
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 deepmerge from "deepmerge";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
@@ -40,7 +29,6 @@ afterAll(() => CloseServerConnection());
describe("POST /ir/kshook/sv6c/score/save", () => {
beforeEach(async () => {
await ResetDBState();
await seedPgUserAndApiToken();
});
@@ -138,7 +126,6 @@ describe("POST /ir/kshook/sv6c/score/export", () => {
.send({ scores: [data] });
beforeEach(async () => {
await ResetDBState();
await seedPgUserAndApiToken();
await DB.insertInto("svc_kshook_sv6c_settings")
.values({ user_id: IR_USER_ID, force_static_import: true })
@@ -1,56 +0,0 @@
import type { ScoreDocument } from "tachi-common";
import ResetDBState from "#test-utils/resets";
import t from "tap";
import MONGODB_KILL from "./db";
t.test("ID field autoprojection", async (t) => {
await ResetDBState();
const res = await MONGODB_KILL.scores.findOne();
t.equal(res!._id, undefined);
const res2 = await MONGODB_KILL.scores.findOne({}, { projectID: true });
t.not(res2!._id, undefined);
const res3 = await MONGODB_KILL.scores.findOne({}, { projection: { scoreID: 1 } });
t.equal(res3!._id, undefined);
const res4 = await MONGODB_KILL.scores.findOne(
{},
{ projection: { scoreID: 1 }, projectID: true },
);
t.not(res4!._id, undefined);
const res5 = await MONGODB_KILL.scores.findOne({}, { projection: { scoreID: 0 } });
t.equal(res5!._id, undefined);
const res6 = await MONGODB_KILL.scores.findOne(
{},
{ projection: { scoreID: 0 }, projectID: true },
);
t.not(res6!._id, undefined);
t.end();
});
// Literally no fix for this? MongoDB just mutates objects you give it
// for fun, i guess.
t.test("Don't add stuff onto my objects please", async (t) => {
const sc = { scoreID: "foo" } as unknown as ScoreDocument;
await MONGODB_KILL.scores.insert(sc);
t.strictSame(sc, { scoreID: "foo" }, "Shouldn't have _id attached onto it.");
await MONGODB_KILL.scores.remove({});
t.end();
});
-224
View File
@@ -1,224 +0,0 @@
import type { OrphanScoreDocument } from "#lib/score-import/import-types/common/types";
import type { MigrationDocument, PrivateUserInfoDocument } from "#utils/types";
import monk, { type ICollection, type TMiddleware } from "monk";
import {
allSupportedGameGroups,
type FolderChartLookup,
type GameGroup,
type GPTStrings,
type integer,
type MONGO_APITokenDocument,
type MONGO_BMSCourseDocument,
type MONGO_CGCardInfo,
type MONGO_ChartDocument,
type MONGO_ClassAchievementDocument,
type MONGO_CounterDocument,
type MONGO_FervidexSettingsDocument,
type MONGO_FolderDocument,
type MONGO_GoalDocument,
type MONGO_GoalSubscriptionDocument,
type MONGO_ImportDocument,
type MONGO_ImportTimingsDocument,
type MONGO_ImportTrackerDocument as MONGO_ImportTrackerDocument,
type MONGO_InviteCodeDocument,
type MONGO_KaiAuthDocument,
type MONGO_KsHookSettingsDocument,
type MONGO_MytCardInfo,
type MONGO_NotificationDocument,
type MONGO_OrphanChartDocument,
type MONGO_PBScoreDocument,
type MONGO_QuestDocument,
type MONGO_QuestlineDocument,
type MONGO_QuestSubscriptionDocument,
type MONGO_RecentlyViewedFolderDocument,
type MONGO_ScoreDocument,
type MONGO_SessionDocument,
type MONGO_SongDocument,
type MONGO_TableDocument,
type MONGO_TachiAPIClientDocument,
type MONGO_UGPTSettingsDocument,
type MONGO_UserDocument,
type MONGO_UserGameStats,
type MONGO_UserGameStatsSnapshotDocument,
type MONGO_UserNameChangeDocument,
type MONGO_UserSettingsDocument,
} from "tachi-common";
// ^ These rules are disabled for good reason. We have to deal with some very nonsensical types here
// so we just disable these rules. I know, it sucks, but we'll live.
import { ONE_MINUTE, ONE_SECOND } from "#lib/constants/time";
import { log } from "#lib/log/log";
import { Env, ServerConfig } from "#lib/setup/config";
import { GetMillisecondsSince } from "#utils/misc";
let dbName = ServerConfig.MONGO_DATABASE_NAME;
/* istanbul ignore next */
if (Env.NODE_ENV === "test") {
dbName = `testingdb`;
}
log.info({ bootInfo: true }, `Connecting to database ${Env.MONGO_URL}/${dbName}...`);
const dbtime = process.hrtime.bigint();
export const monkDB = monk(`${Env.MONGO_URL}/${dbName}`, {
// Various things cause bizarre issues with mongodb connections. Windows+Docker especially so.
// 5 minutes is excessive, but believe it or not, some setups are exceeding 2 minutes!
serverSelectionTimeoutMS: ONE_MINUTE * 5,
// in local dev, don't **ever** add _id onto objects you're inserting
// in production, this might have a performance hit.
forceServerObjectId: Env.NODE_ENV === "test",
});
/* istanbul ignore next */
monkDB
.then(() => {
log.info(
{
bootInfo: true,
},
`Database connection successful: took ${GetMillisecondsSince(dbtime)}ms`,
);
})
.catch((err) => {
log.fatal(`Failed to connect to database: ${err}`);
// can't connect. kill self after 1 second.
setTimeout(() => {
process.exit(1);
}, ONE_SECOND);
});
/**
* Removes _id from the returns of find/findOne. Note that this function is littered with eslint-disables
* due to it working with some pretty wobbly types.
*/
const RemoveIDFromFindReturnsMiddleware: TMiddleware = () => (next) => (args: any, method) => {
if ((method === "find" || method === "findOne") && !args.options.projectID) {
if (args.options.projection) {
args.options.projection._id = 0;
} else {
args.options.projection = { _id: 0 };
}
}
return next(args, method);
};
// a bug in monks types means that :any has to be used here. Maybe we'll make a PR for this?
const StripIDFromDBInsertsMiddleware: TMiddleware = () => (next) => (args: any, method) => {
if (method === "insert") {
if (Array.isArray(args.data)) {
for (const d of args.data) {
delete d._id;
}
} else {
delete args.data._id;
}
}
return next(args, method);
};
monkDB.addMiddleware(StripIDFromDBInsertsMiddleware);
monkDB.addMiddleware(RemoveIDFromFindReturnsMiddleware);
export async function CloseMongoConnection() {
await monkDB.close();
}
// Typescript incorrectly casts this into songs[string] => MONGO_SongDocument,
// Force cast it out.
const songs = Object.fromEntries(
allSupportedGameGroups.map((e) => [e, monkDB.get<MONGO_SongDocument>(`songs-${e}`)]),
) as unknown as {
[G in GameGroup]: ICollection<MONGO_SongDocument<G>>;
};
const charts = Object.fromEntries(
allSupportedGameGroups.map((e) => [e, monkDB.get<MONGO_ChartDocument>(`charts-${e}`)]),
) as unknown as {
[G in GameGroup]: ICollection<MONGO_ChartDocument<GPTStrings[G]>>;
};
const MONGODB_KILL = {
// i have to handwrite this out for TS... :(
// dont worry, it was all macro'd.
songs,
charts,
// intended for when you want to query arbitrary chart/song documents.
anyCharts: charts as Record<GameGroup, ICollection<MONGO_ChartDocument>>,
anySongs: songs as Record<GameGroup, ICollection<MONGO_SongDocument>>,
scores: monkDB.get<MONGO_ScoreDocument>("scores"),
"personal-bests": monkDB.get<MONGO_PBScoreDocument>("personal-bests"),
folders: monkDB.get<MONGO_FolderDocument>("folders"),
"folder-chart-lookup": monkDB.get<FolderChartLookup>("folder-chart-lookup"),
goals: monkDB.get<MONGO_GoalDocument>("goals"),
"goal-subs": monkDB.get<MONGO_GoalSubscriptionDocument>("goal-subs"),
quests: monkDB.get<MONGO_QuestDocument>("quests"),
"quest-subs": monkDB.get<MONGO_QuestSubscriptionDocument>("quest-subs"),
users: monkDB.get<MONGO_UserDocument>("users"),
imports: monkDB.get<MONGO_ImportDocument>("imports"),
"import-timings": monkDB.get<MONGO_ImportTimingsDocument>("import-timings"),
sessions: monkDB.get<MONGO_SessionDocument>("sessions"),
invites: monkDB.get<MONGO_InviteCodeDocument>("invites"),
counters: monkDB.get<MONGO_CounterDocument>("counters"),
"game-stats": monkDB.get<MONGO_UserGameStats>("game-stats"),
"kai-auth-tokens": monkDB.get<MONGO_KaiAuthDocument>("kai-auth-tokens"),
"cg-card-info": monkDB.get<MONGO_CGCardInfo>("cg-card-info"),
"myt-card-info": monkDB.get<MONGO_MytCardInfo>("myt-card-info"),
"bms-course-lookup": monkDB.get<MONGO_BMSCourseDocument>("bms-course-lookup"),
"api-tokens": monkDB.get<MONGO_APITokenDocument>("api-tokens"),
"orphan-scores": monkDB.get<OrphanScoreDocument>("orphan-scores"),
"import-locks": monkDB.get<{ locked: boolean; lockedAt: integer | null; userID: integer }>(
"import-locks",
),
tables: monkDB.get<MONGO_TableDocument>("tables"),
"invite-locks": monkDB.get<{ locked: boolean; userID: integer }>("invite-locks"),
"game-settings": monkDB.get<MONGO_UGPTSettingsDocument>("game-settings"),
"game-stats-snapshots": monkDB.get<MONGO_UserGameStatsSnapshotDocument>("game-stats-snapshots"),
"user-settings": monkDB.get<MONGO_UserSettingsDocument>("user-settings"),
"user-private-information": monkDB.get<PrivateUserInfoDocument>("user-private-information"),
"api-clients": monkDB.get<MONGO_TachiAPIClientDocument>("api-clients"),
// i've inlined this one because i don't see it appearing anywhere else.
"oauth2-auth-codes": monkDB.get<{ code: string; createdOn: number; userID: integer }>(
"oauth2-auth-codes",
),
"fer-settings": monkDB.get<MONGO_FervidexSettingsDocument>("fer-settings"),
"kshook-sv6c-settings": monkDB.get<MONGO_KsHookSettingsDocument>("kshook-sv6c-settings"),
"orphan-chart-queue": monkDB.get<MONGO_OrphanChartDocument>("orphan-chart-queue"),
"password-reset-codes": monkDB.get<{
code: string;
createdOn: number;
userID: integer;
}>("password-reset-codes"),
"class-achievements": monkDB.get<MONGO_ClassAchievementDocument>("class-achievements"),
"score-blacklist": monkDB.get<{ score: MONGO_ScoreDocument; scoreID: string; userID: integer }>(
"score-blacklist",
),
"verify-email-codes": monkDB.get<{ code: string; email: string; userID: integer }>(
"verify-email-codes",
),
"recent-folder-views": monkDB.get<MONGO_RecentlyViewedFolderDocument>("recent-folder-views"),
questlines: monkDB.get<MONGO_QuestlineDocument>("questlines"),
migrations: monkDB.get<MigrationDocument>("migrations"),
notifications: monkDB.get<MONGO_NotificationDocument>("notifications"),
"import-trackers": monkDB.get<MONGO_ImportTrackerDocument>("import-trackers"),
"user-name-changes": monkDB.get<MONGO_UserNameChangeDocument>("user-name-changes"),
};
export type StaticDatabases = Exclude<
keyof typeof MONGODB_KILL,
"anyCharts" | "anySongs" | "charts" | "songs"
>;
export type Databases = `charts-${GameGroup}` | `songs-${GameGroup}` | StaticDatabases;
export default MONGODB_KILL;
@@ -1,220 +0,0 @@
import type { IndexOptions } from "mongodb";
import monk, { type IMonkManager } from "monk";
/* eslint-disable no-await-in-loop */
import { log } from "#lib/log/log";
import { TachiConfig } from "#lib/setup/config";
import { DedupeArr, Random20Hex } from "#utils/misc";
import { GAME_PT_CONFIGS } from "tachi-common";
import type { Databases } from "./db";
interface Index {
fields: Record<string, unknown>;
options?: IndexOptions;
}
function index(fields: Record<string, unknown>, options?: IndexOptions) {
return { fields, options };
}
const UNIQUE = { unique: true };
const allDefaultMetrics = DedupeArr(Object.values(GAME_PT_CONFIGS).map((e) => e.defaultMetric));
// NOTE: INDEXES REMOVED FROM HERE WILL **NOT** AUTOMATICALLY UPDATE.
// YOU NEED TO RUN src/scripts/set-indexes.ts --reset TO REMOVE INDEXES.
// (indexes added will automatically apply on tachi-server boot, though).
const staticIndexes: Partial<Record<Databases, Array<Index>>> = {
scores: [
index({ scoreID: 1 }, UNIQUE),
index({ chartID: 1, userID: 1 }),
index({ userID: 1, game: 1, playtype: 1 }),
// activity
index({ game: 1, playtype: 1, highlight: 1, timeAchieved: 1 }),
],
"personal-bests": [
index({ chartID: 1, userID: 1 }, UNIQUE),
// one index for every default metric, why not.
...allDefaultMetrics.map((e) => index({ chartID: 1, [`scoreData.${e}`]: 1 })),
index({ userID: 1, game: 1, playtype: 1 }),
// activity
index({ game: 1, playtype: 1, timeAchieved: 1 }),
],
sessions: [
index({ sessionID: 1 }, UNIQUE),
index({ game: 1, playtype: 1, timeStarted: 1 }),
index({ userID: 1, game: 1, playtype: 1, timeStarted: 1, timeEnded: 1 }),
// Optimises score modification, since sessions need to be repointed.
// also, just generally useful.
index({ scoreIDs: 1 }),
index({ name: "text" }),
],
"game-stats": [index({ userID: 1, game: 1, playtype: 1 }, UNIQUE)],
"game-settings": [index({ userID: 1, game: 1, playtype: 1 }, UNIQUE), index({ rivals: 1 })],
"folder-chart-lookup": [
index({ chartID: 1, folderID: 1 }, UNIQUE),
index({ chartID: 1 }),
index({ folderID: 1 }),
],
goals: [index({ goalID: 1 }, UNIQUE), index({ game: 1, playtype: 1 }), index({ name: "text" })],
"goal-subs": [index({ goalID: 1, userID: 1 }, UNIQUE), index({ goalID: 1 })],
quests: [
index({ questID: 1 }, UNIQUE),
index({ game: 1, playtype: 1 }),
index({ name: "text" }),
],
"quest-subs": [
index({ questID: 1, userID: 1 }, UNIQUE),
index({ userID: 1, game: 1, playtype: 1 }),
],
questlines: [
index({ questlineID: 1 }, UNIQUE),
index({ game: 1, playtype: 1 }),
index({ quests: 1 }),
index({ name: "text" }),
],
imports: [index({ importID: 1 }, UNIQUE)],
"import-timings": [
index({ importID: 1 }, UNIQUE),
index({ timestamp: 1 }),
index({ total: 1 }),
],
users: [
index({ id: 1 }, UNIQUE),
index({ id: 1, authLevel: 1 }, UNIQUE),
index({ username: 1 }, UNIQUE),
index({ usernameLowercase: 1 }, UNIQUE),
],
folders: [
index({ folderID: 1 }, UNIQUE),
index({ game: 1, playtype: 1, table: 1, tableIndex: 1 }),
index({ title: "text", searchTerms: "text" }),
],
"kai-auth-tokens": [index({ userID: 1, service: 1 }, UNIQUE)],
"myt-card-info": [index({ userID: 1 }, UNIQUE)],
"bms-course-lookup": [index({ md5sums: 1 }, UNIQUE)],
"api-tokens": [index({ token: 1 }, UNIQUE), index({ userID: 1 })],
tables: [index({ tableID: 1, game: 1, playtype: 1 }, UNIQUE)],
"game-stats-snapshots": [index({ timestamp: 1, userID: 1, game: 1, playtype: 1 }, UNIQUE)],
"user-settings": [index({ userID: 1 }, UNIQUE)],
"user-private-information": [index({ userID: 1 }, UNIQUE), index({ email: 1 }, UNIQUE)],
"fer-settings": [index({ userID: 1 }, UNIQUE)],
"kshook-sv6c-settings": [index({ userID: 1 }, UNIQUE)],
counters: [index({ counterName: 1 }, UNIQUE)],
"class-achievements": [index({ game: 1, playtype: 1, timeAchieved: 1 })],
"api-clients": [index({ clientID: 1 }, UNIQUE)],
"charts-iidx": [
index({ "data.hashSHA256": 1 }),
index({ "data.inGameID": 1, playtype: 1, difficulty: 1 }),
],
"charts-bms": [index({ "data.hashMD5": 1 }, UNIQUE), index({ "data.hashSHA256": 1 }, UNIQUE)],
"charts-popn": [index({ "data.hashSHA256": 1 }, UNIQUE)],
"charts-sdvx": [index({ "data.inGameID": 1, difficulty: 1 })],
"charts-museca": [index({ "data.inGameID": 1, difficulty: 1 }, UNIQUE)],
"charts-chunithm": [index({ "data.inGameID": 1, difficulty: 1 }, UNIQUE)],
"charts-gitadora": [index({ "data.inGameID": 1, difficulty: 1, playtype: 1 }, UNIQUE)],
"charts-wacca": [index({ "data.inGameID": 1, difficulty: 1 }, UNIQUE)],
"charts-usc": [index({ "data.hashSHA1": 1, playtype: 1 }, UNIQUE)],
"charts-jubeat": [index({ "data.inGameID": 1, difficulty: 1 }, UNIQUE)],
"charts-pms": [
index({ "data.hashSHA256": 1, playtype: 1 }, UNIQUE),
index({ "data.hashMD5": 1, playtype: 1 }, UNIQUE),
],
"charts-maimai": [
index({ "data.inGameID": 1, difficulty: 1 }, UNIQUE),
index({ "data.inGameStrID": 1, difficulty: 1 }, UNIQUE),
],
"import-locks": [index({ userID: 1 }, UNIQUE)],
"score-blacklist": [index({ scoreID: 1 }, UNIQUE)],
migrations: [index({ migrationID: 1 }, UNIQUE)],
notifications: [index({ notifID: 1 }, UNIQUE), index({ sentTo: 1, sentAt: 1 })],
};
const indexes: Partial<Record<Databases, Array<Index>>> = staticIndexes;
for (const game of TachiConfig.GAMES) {
if (indexes[`charts-${game}` as Databases]) {
// @ts-expect-error this is obviously fine
indexes[`charts-${game}` as Databases].push(
index({ chartID: 1 }, UNIQUE),
index(
{ songID: 1, difficulty: 1, playtype: 1, isPrimary: 1 },
{ unique: true, partialFilterExpression: { isPrimary: { $eq: true } } },
),
index({ songID: 1 }),
index({ playtype: 1 }),
);
} else {
indexes[`charts-${game}` as Databases] = [
index({ chartID: 1 }, UNIQUE),
index({ songID: 1, difficulty: 1, playtype: 1, isPrimary: 1 }, UNIQUE),
index({ songID: 1 }),
index({ playtype: 1 }),
];
}
if (indexes[`songs-${game}` as Databases]) {
// @ts-expect-error this is obviously fine
indexes[`songs-${game}` as Databases].push(
index({ id: 1 }, UNIQUE),
index({ title: "text", artist: "text", altTitles: "text", searchTerms: "text" }),
);
} else {
indexes[`songs-${game}` as Databases] = [
index({ id: 1 }, UNIQUE),
index({ title: 1 }),
index({ title: "text", artist: "text", altTitles: "text", searchTerms: "text" }),
];
}
}
export async function UpdateIndexes(db: IMonkManager, reset: boolean) {
const collections = (await db.listCollections()).map((e) => e.name);
for (const [collection, values] of Object.entries(indexes)) {
if (!collections.includes(collection)) {
// this creates a collection, i cant find the createCollection
// call.
const tmp = Random20Hex();
await db.get(collection).insert({ __tmp: tmp });
await db.get(collection).remove({ __tmp: tmp });
}
if (reset) {
await db.get(collection).dropIndexes();
log.debug(`Reset ${collection}.`);
}
for (const index of values) {
try {
// @ts-expect-error Type-mismatch here. our index.fields are just boring records. I know
// that this sucks...
const r = await db.get(collection).createIndex(index.fields, index.options);
log.debug(r);
} catch (err) {
log.debug(err);
// index already exists.
}
}
}
log.debug("Done.");
}
export async function SetIndexes(mongoUrl: string, reset: boolean) {
const monkDb = monk(mongoUrl);
await UpdateIndexes(monkDb, reset);
await monkDb.close();
}
@@ -1,112 +0,0 @@
import { TachiConfig } from "#lib/setup/config";
import { p, type PrudenceSchema } from "prudence";
import { allImportTypes } from "tachi-common/constants/import-types";
import { SCHEMAS, type SchemaValidatorFunction } from "tachi-common/lib/schemas";
import type { Databases } from "./db";
// The idea of this file is to export a function for every database
// that will validate that collection.
// If it returns true, that document is valid. If it throws any sort
// of error, it is invalid.
function prSchemaify(schema: PrudenceSchema) {
return (s: unknown): true => {
const err = p(s, schema);
if (err) {
throw err;
}
return true;
};
}
export const DatabaseSchemas: Record<Databases, SchemaValidatorFunction> = {
"user-name-changes": prSchemaify({
userID: p.isPositiveNonZeroInteger,
username: "string",
timestamp: p.isPositiveInteger,
previousUsername: "string",
}),
"import-locks": prSchemaify({
userID: p.isPositiveNonZeroInteger,
locked: "boolean",
}),
"invite-locks": prSchemaify({
userID: p.isPositiveNonZeroInteger,
locked: "boolean",
}),
"kai-auth-tokens": prSchemaify({
userID: p.isPositiveNonZeroInteger,
token: "string",
refreshToken: "string",
service: p.isIn("FLO", "EAG", "MIN"),
}),
"cg-card-info": prSchemaify({
userID: p.isPositiveNonZeroInteger,
service: p.isIn("dev", "gan", "nag"),
cardID: "string",
pin: "string",
}),
"myt-card-info": prSchemaify({
userID: p.isPositiveNonZeroInteger,
cardAccessCode: "string",
}),
"oauth2-auth-codes": prSchemaify({
code: "string",
userID: p.isPositiveNonZeroInteger,
createdOn: p.isPositive,
}),
"password-reset-codes": prSchemaify({
code: "string",
userID: p.isPositiveNonZeroInteger,
createdOn: p.isPositive,
}),
"verify-email-codes": prSchemaify({
userID: p.isPositiveNonZeroInteger,
code: "string",
email: "string",
}),
migrations: prSchemaify({
migrationID: "string",
status: p.isIn("applied", "pending"),
appliedOn: (self, parent) => {
if (parent.status === "pending") {
return (
self === undefined ||
`Expected appliedOn to be undefined since status was pending.`
);
}
return p.isPositiveInteger(self);
},
}),
"orphan-scores": prSchemaify({
importType: p.isIn(TachiConfig.IMPORT_TYPES),
game: p.isIn(TachiConfig.GAMES),
userID: p.isPositiveNonZeroInteger,
orphanID: "string",
timeInserted: p.isPositive,
errMsg: "?string",
// @todo Type these properly? This will take a lot of effort, as it will require
// formal types for all parser data:context duos.
data: p.any,
context: p.any,
}),
"import-trackers": prSchemaify({
timeStarted: p.isPositive,
importID: "string",
userID: p.isPositiveInteger,
importType: p.isIn(allImportTypes),
userIntent: "boolean",
type: p.isIn("ONGOING", "FAILED"),
error: p.optional({
message: "string",
statusCode: "*number",
}),
}),
...SCHEMAS,
};
@@ -1,36 +0,0 @@
import { p, type PrudenceSchema } from "prudence";
export function isApproximately(
t: Tap.Test,
number: number,
target: number,
message?: string,
lenience = 0.01,
) {
const result = Math.abs(number - target) < lenience;
if (!result) {
return t.fail(`${number} was not close enough to ${target}`, {
errmsg: `${number} was not close enough to ${target}`,
});
}
t.pass(message);
// return true;
}
export function prAssert(
t: Tap.Test,
obj: unknown,
schema: PrudenceSchema,
message = "Unnamed Prudence Assertion",
) {
const err = p(obj, schema);
if (err) {
return t.fail(message, err);
}
return t.pass(message);
}
@@ -1,17 +0,0 @@
import { CloseScoreImportQueue } from "#lib/score-import/worker/queue";
import { CloseMongoConnection } from "#services/mongo/db";
import { CloseRedisConnection } from "#services/redis/redis";
import { CloseServerConnection } from "./mock-api";
import { WriteSnapshotData } from "./single-process-snapshot";
export async function CleanUpAfterTests() {
if (process.env.TAP_SNAPSHOT !== "" && process.env.TAP_SNAPSHOT !== undefined) {
WriteSnapshotData();
}
await CloseMongoConnection();
await CloseServerConnection();
await CloseRedisConnection();
await CloseScoreImportQueue();
}
@@ -1,10 +0,0 @@
// This runs as JS because of https://github.com/tapjs/node-tap/issues/761
const assert = require("assert");
const { execSync } = require("child_process");
const path = require("path");
console.log("Starting test setup via hack-setup.js");
assert(process.env.NODE_ENV === "test", "NODE_ENV WAS NOT TEST, BAILING");
execSync(`ts-node ${path.join(__dirname, "./setup.ts")}`, { stdio: "inherit" });
@@ -1,6 +1,49 @@
import { HashPassword } from "#lib/auth/auth";
import DB from "#services/pg/db";
let minimalIidxChartCounter = 0;
/**
* Inserts a minimal `song` + `chart` row for `iidx` / `SP` (`game` = `iidx-sp`) so
* goal/chart validation (`GetChartById`) succeeds in tests.
*/
export async function seedMinimalIidxSpChart() {
const n = ++minimalIidxChartCounter;
const songId = `S_MIN_IIDX_${n}_${Date.now()}`;
const chartId = `C_MIN_IIDX_${n}_${Date.now()}`;
await DB.insertInto("song")
.values({
id: songId,
legacy_id: 90_000 + n,
game_group: "iidx",
title: "Minimal IIDX Test Chart",
artist: "Tester",
search_terms: [],
alt_titles: [],
data: { displayVersion: "1", genre: "TEST" },
fts_document: "",
})
.execute();
await DB.insertInto("chart")
.values({
id: chartId,
legacy_id: `c_min_iidx_legacy_${n}`,
game: "iidx-sp",
song_id: songId,
difficulty: "ANOTHER",
level: "10",
level_num: 10,
is_primary: true,
versions: ["27"],
data: { inGameID: 1000, notecount: 786 },
})
.execute();
return chartId;
}
// ─── seedUser ─────────────────────────────────────────────────────────────────
interface SeedUserOpts {
@@ -1,19 +0,0 @@
import { log } from "#lib/log/log";
import { Env } from "#lib/setup/config";
import { SetIndexes } from "#services/mongo/indexes";
export default async function ResetDBState() {
// not used
}
export async function SetIndexesForDB() {
await ResetDBState();
const url = `${Env.MONGO_URL}/testingdb`;
log.info(`Setting indexes for ${url}`);
await SetIndexes(url, true);
log.info(`Done.`);
return true;
}
@@ -1,8 +0,0 @@
import supertest from "supertest";
import server from "../server/server";
const mockServer = supertest(server);
// mock the server for requests
export default mockServer;
-13
View File
@@ -1,13 +0,0 @@
import { monkDB } from "#services/mongo/db";
import { SetIndexesForDB } from "./resets";
SetIndexesForDB()
.then(monkDB.close)
.then(() => process.exit(0))
.catch((err) => {
// we might not *have* a workinglog here
console.error(err);
process.exit(1);
});
@@ -1,36 +0,0 @@
import { IsNonEmptyString } from "#utils/misc";
import fs from "fs";
import path from "path";
const SNAP_PATH = path.join(__dirname, "./snapshots/snapshot-data.json");
type Snapshots = Record<string, boolean | number | string>;
function ReadSnapshotData() {
let snapshots: Snapshots = {};
if (fs.existsSync(SNAP_PATH)) {
snapshots = JSON.parse(fs.readFileSync(SNAP_PATH, "utf-8")) as Snapshots;
}
return snapshots;
}
export function WriteSnapshotData() {
fs.writeFileSync(SNAP_PATH, JSON.stringify(snapshotData, null, "\t"));
}
const snapshotData = ReadSnapshotData();
export function TestSnapshot(t: Tap.Test, value: any, testName: string) {
if (IsNonEmptyString(process.env.TAP_SNAPSHOT)) {
snapshotData[testName] = value;
WriteSnapshotData();
} else {
if (snapshotData[testName] === undefined) {
return t.fail(`No snapshot exists for ${testName}. Have you ran pnpm snap?`);
}
t.strictSame(value, snapshotData[testName], `Snapshot: ${testName}`);
}
}
@@ -1,18 +0,0 @@
import "#services/mongo/db";
import glob from "glob";
import path from "path";
import t from "tap";
import { CleanUpAfterTests } from "./cleanup";
const files = glob.sync(path.join(__dirname, "../", "**/*.oldtest.ts"));
process.env.NODE_PATH = path.join(__dirname, "../");
for (const file of files) {
// Deliberate -- we're doing hackery here.
// eslint-disable-next-line @typescript-eslint/no-require-imports
require(file);
}
t.teardown(CleanUpAfterTests);
@@ -1,13 +0,0 @@
import t from "tap";
import { HashSHA256 } from "./crypto";
t.test("#HashSHA256", (t) => {
t.equal(
HashSHA256(Buffer.from("something")),
"3fc9b689459d738f8c88a3a48aa9e33542016b7a4052e001aaa536fca74813cb",
"'something' should hash into its expected value.",
);
t.end();
});
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { HashSHA256 } from "./crypto";
describe("HashSHA256", () => {
it("hashes 'something' to the expected SHA-256 hex digest", () => {
expect(HashSHA256(Buffer.from("something"))).toBe(
"3fc9b689459d738f8c88a3a48aa9e33542016b7a4052e001aaa536fca74813cb",
);
});
});
@@ -13,11 +13,11 @@ describe("GetNextBmsPmsSongLegacyId", () => {
const base = maxRow?.m ?? 0;
const newLegacyId = base + 1;
const songPgId = `test-song-pms-legacy-seq-${newLegacyId}`;
const songNewID = `test-song-pms-legacy-seq-${newLegacyId}`;
await DB.insertInto("song")
.values({
id: songPgId,
id: songNewID,
legacy_id: newLegacyId,
game_group: "pms",
title: "t",
@@ -33,6 +33,6 @@ describe("GetNextBmsPmsSongLegacyId", () => {
expect(n).toBe(newLegacyId + 1);
await DB.deleteFrom("song").where("id", "=", songPgId).execute();
await DB.deleteFrom("song").where("id", "=", songNewID).execute();
});
});
+30 -11
View File
@@ -172,18 +172,10 @@ export async function GetRelevantSongsAndCharts(
return { songs, charts };
}
/** Mongo seed hook used to sync `*-song-id` counters after chart loads. Under Postgres the next id is always `max(legacy_id)+1` at allocation time — nothing to sync. */
export async function UpdateGameSongIDCounter(game: "bms" | "pms") {
log.debug(
`UpdateGameSongIDCounter(${game}) — no-op (next legacy id comes from max(legacy_id) + 1).`,
);
}
export async function GetChartForIDGuaranteed(game: GameGroup, chartID: string) {
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", game)
.where("chart.id", "=", chartID)
.executeTakeFirst();
@@ -192,7 +184,7 @@ export async function GetChartForIDGuaranteed(game: GameGroup, chartID: string)
throw new Error(`Couldn't find chart with ID ${chartID} (${game}).`);
}
return ToChartDocument(row, row.song_legacy_id);
return ToChartDocument(row);
}
export async function GetSongForIDGuaranteed(game: GameGroup, songID: integer) {
@@ -231,6 +223,24 @@ export async function GetGoalForIDGuaranteed(goalID: string) {
return ToGoalDocument(row);
}
export async function GetGoalSubscriptionForIDGuaranteed(goalID: string, userID: integer) {
const row = await DB.selectFrom("goal_sub")
.innerJoin("goal", "goal.id", "goal_sub.goal_id")
.selectAll("goal_sub")
.select("goal.game as goal_game")
.where("goal_id", "=", goalID)
.where("user_id", "=", userID)
.executeTakeFirst();
if (!row) {
throw new Error(
`Couldn't find goal subscription with goalID ${goalID} and userID ${userID}`,
);
}
return ToGoalSubscriptionDocument(row);
}
export async function GetQuestForIDGuaranteed(questID: string) {
const row = await DB.selectFrom("quest")
.selectAll()
@@ -669,9 +679,13 @@ export async function GetMostSubscribedQuests(
}
export async function GetChildQuests(questline: MONGO_QuestlineDocument) {
if (questline.quests.length === 0) {
return [];
}
const quests = await DB.selectFrom("quest")
.selectAll()
.where("id", "in", questline.quests)
.where("quest.id", "in", questline.quests)
.execute();
if (quests.length !== questline.quests.length) {
@@ -681,5 +695,10 @@ export async function GetChildQuests(questline: MONGO_QuestlineDocument) {
);
}
return quests.map(ToQuestDocument);
const byId = new Map(quests.map((q) => [q.id, q]));
return questline.quests
.map((id) => byId.get(id))
.filter((q): q is NonNullable<typeof q> => q !== undefined)
.map(ToQuestDocument);
}
+74
View File
@@ -0,0 +1,74 @@
import {
EXT_CANNON_BALLERS,
EXT_HEROIC_VERSE,
MODEL_IIDX,
MODEL_INFINITAS_2,
MODEL_SDVX3_KONASTE,
REV_2DXBMS,
REV_NORMAL,
} from "#lib/constants/ea3id";
import { describe, expect, it } from "vitest";
import { ParseEA3SoftID } from "./ea3id";
describe("ParseEA3SoftID", () => {
it("parses a valid IIDX soft ID built from constants", () => {
const ver = `${MODEL_IIDX}:J:A:${REV_2DXBMS}:${EXT_CANNON_BALLERS}`;
expect(ParseEA3SoftID(ver)).toEqual({
model: MODEL_IIDX,
dest: "J",
spec: "A",
rev: REV_2DXBMS,
ext: EXT_CANNON_BALLERS,
});
});
it("parses Infinitas 2 and SDVX Konaste-style IDs", () => {
expect(
ParseEA3SoftID(`${MODEL_INFINITAS_2}:J:A:${REV_NORMAL}:${EXT_HEROIC_VERSE}`),
).toEqual({
model: MODEL_INFINITAS_2,
dest: "J",
spec: "A",
rev: REV_NORMAL,
ext: EXT_HEROIC_VERSE,
});
expect(ParseEA3SoftID(`${MODEL_SDVX3_KONASTE}:J:A:${REV_NORMAL}:2024010100`)).toEqual({
model: MODEL_SDVX3_KONASTE,
dest: "J",
spec: "A",
rev: REV_NORMAL,
ext: "2024010100",
});
});
it("throws when the string does not split into exactly five components", () => {
expect(() => ParseEA3SoftID("LDJ:J:A:Z")).toThrow(
"Invalid Version Code. Had 4 components.",
);
expect(() => ParseEA3SoftID("LDJ:J:A:Z:2018091900:extra")).toThrow(
"Invalid Version Code. Had 6 components.",
);
});
it("throws when the full string does not match the EA3 format", () => {
expect(() =>
ParseEA3SoftID(`${MODEL_IIDX}:J:A:${REV_2DXBMS}:${EXT_CANNON_BALLERS}x`),
).toThrow("Invalid Version Code.");
expect(() => ParseEA3SoftID(`ldj:J:A:${REV_2DXBMS}:${EXT_CANNON_BALLERS}`)).toThrow(
"Invalid Version Code.",
);
expect(() => ParseEA3SoftID(`XX:J:A:${REV_2DXBMS}:${EXT_CANNON_BALLERS}`)).toThrow(
"Invalid Version Code.",
);
expect(() => ParseEA3SoftID(`${MODEL_IIDX}:J:A:${REV_2DXBMS}:201809190`)).toThrow(
"Invalid Version Code.",
);
});
});
@@ -1,46 +0,0 @@
import type { ICollection, IObjectID } from "monk";
/* eslint-disable no-await-in-loop */
import { log } from "#lib/log/log";
export async function EfficientDBIterate<T extends object, R>(
collection: ICollection<T>,
callbackFn: (c: T) => Promise<R> | R,
saveOp: (c: Array<R>) => Promise<void>,
filter: object = {},
bucketSize = 10_000,
) {
let i = 0;
let lastID: IObjectID | null = null;
while (true) {
log.info(`Running on ${i} - ${i + bucketSize} documents.`);
const newFilter: any = { ...filter };
if (lastID) {
newFilter._id = { $gt: lastID };
}
const docs = await collection.find(newFilter, {
sort: { _id: 1 },
limit: bucketSize,
projectID: true,
});
// update lastID by taking the last document's ID.
lastID = docs.at(-1)?._id ?? null;
if (docs.length === 0) {
log.info(`Ended documents at ${i}.`);
break;
}
const rDocs = await Promise.all(docs.map(callbackFn));
i = i + bucketSize;
await saveOp(rDocs);
}
}
+1
View File
@@ -1,3 +1,4 @@
// TODO(zk): remove this - this is stupid
export {
BuildFolderQuery,
CreateFolderID,
@@ -1,66 +0,0 @@
import t from "tap";
import {
DeleteUndefinedProps,
GetMillisecondsSince,
IsValidURL,
RoundToNDecimalPlaces,
} from "./misc";
t.test("#GetMillisecondsSince", (t) => {
const time = GetMillisecondsSince(10n);
t.ok(typeof time === "number" && time > 0, "Should return a number greater than 0.");
t.end();
});
t.test("#DeleteUndefinedProps", (t) => {
const v = {
a: 1,
b: 2,
c: undefined,
d: {
e: 3,
f: undefined,
},
};
DeleteUndefinedProps(v);
t.strictSame(v, {
a: 1,
b: 2,
d: {
e: 3,
},
});
t.end();
});
t.test("#IsValidURL", (t) => {
t.ok(IsValidURL("https://example.com"));
t.ok(IsValidURL("http://example.com"));
t.ok(IsValidURL("http://example.com/suburl"));
t.ok(IsValidURL("http://example.com#href"));
t.ok(IsValidURL("http://example.com?querystring"));
t.ok(IsValidURL("tachi-fdsaf7324hf://example.com?querystring"));
t.notOk(IsValidURL("ftp://example.com"));
// t.notOk(IsValidURL("http://example")); lol this is valid???? insane.
// t.notOk(IsValidURL("http:/example.com")); this is also valid, the JS URL parser is ridiculously lenient. Whatever.
t.end();
});
t.test("#RoundToNDecimalPlaces", (t) => {
t.equal(RoundToNDecimalPlaces(10.4999999999, 1), 10.5);
t.equal(RoundToNDecimalPlaces(10.4999999999, 2), 10.5);
t.equal(RoundToNDecimalPlaces(10.4499999999, 2), 10.45);
t.equal(RoundToNDecimalPlaces(10.4449999999, 3), 10.445);
t.equal(RoundToNDecimalPlaces(10.4469999999, 3), 10.447);
t.end();
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import {
DeleteUndefinedProps,
GetMillisecondsSince,
IsValidURL,
RoundToNDecimalPlaces,
} from "./misc";
describe("GetMillisecondsSince", () => {
it("returns a number greater than 0", () => {
const time = GetMillisecondsSince(10n);
expect(typeof time).toBe("number");
expect(time).toBeGreaterThan(0);
});
});
describe("DeleteUndefinedProps", () => {
it("removes undefined keys recursively", () => {
const v = {
a: 1,
b: 2,
c: undefined,
d: {
e: 3,
f: undefined,
},
};
DeleteUndefinedProps(v);
expect(v).toEqual({
a: 1,
b: 2,
d: {
e: 3,
},
});
});
});
describe("IsValidURL", () => {
it("accepts common http(s) and custom-scheme URLs", () => {
expect(IsValidURL("https://example.com")).toBe(true);
expect(IsValidURL("http://example.com")).toBe(true);
expect(IsValidURL("http://example.com/suburl")).toBe(true);
expect(IsValidURL("http://example.com#href")).toBe(true);
expect(IsValidURL("http://example.com?querystring")).toBe(true);
// Non-http(s) matches return the RegExpExecArray from `.exec()` (truthy).
expect(IsValidURL("tachi-fdsaf7324hf://example.com?querystring")).toBeTruthy();
});
it("rejects ftp URLs", () => {
// Implementation uses `||` chains that can yield `null` for disallowed schemes.
expect(IsValidURL("ftp://example.com")).toBeFalsy();
});
// expect(IsValidURL("http://example")).toBe(false) — lol this is valid???? insane.
// expect(IsValidURL("http:/example.com")).toBe(false) — this is also valid, the JS URL parser is ridiculously lenient. Whatever.
});
describe("RoundToNDecimalPlaces", () => {
it("rounds to N decimal places", () => {
expect(RoundToNDecimalPlaces(10.4999999999, 1)).toBe(10.5);
expect(RoundToNDecimalPlaces(10.4999999999, 2)).toBe(10.5);
expect(RoundToNDecimalPlaces(10.4499999999, 2)).toBe(10.45);
expect(RoundToNDecimalPlaces(10.4449999999, 3)).toBe(10.445);
expect(RoundToNDecimalPlaces(10.4469999999, 3)).toBe(10.447);
});
});
+48 -88
View File
@@ -19,24 +19,11 @@ import {
type Versions,
} from "tachi-common";
type ChartJoinedRow = {
song_legacy_id: number;
} & Parameters<typeof ToChartDocument>[0] extends infer R
? R
: never;
function chartJoinedToDocument(
row: { song_legacy_id: number } & ChartJoinedRow,
): MONGO_ChartDocument {
const { song_legacy_id, ...chartRow } = row;
return ToChartDocument(chartRow, song_legacy_id);
}
export async function FindChartWithChartID(game: GameGroup, chartID: string) {
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", game)
.where("chart.id", "=", chartID)
.executeTakeFirst();
@@ -45,7 +32,7 @@ export async function FindChartWithChartID(game: GameGroup, chartID: string) {
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
/**
@@ -63,7 +50,7 @@ export async function FindChartWithPTDF<
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", game)
.where("song.legacy_id", "=", songID)
.where("chart.game", "=", v3Game)
@@ -75,7 +62,7 @@ export async function FindChartWithPTDF<
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
/**
@@ -92,7 +79,7 @@ export async function FindChartWithPTDFVersion<
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", game)
.where("song.legacy_id", "=", songID)
.where("chart.game", "=", v3Game)
@@ -104,14 +91,14 @@ export async function FindChartWithPTDFVersion<
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
export async function FindITGChartOnHash(hash: string) {
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("chart.game", "=", "itg-stamina" as Game)
.where(sql<boolean>`(chart.data::jsonb->>'hashGSv3') = ${hash}`)
.executeTakeFirst();
@@ -120,7 +107,7 @@ export async function FindITGChartOnHash(hash: string) {
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
/**
@@ -131,7 +118,7 @@ export async function FindBMSChartOnHash(hash: string) {
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", "bms")
.where((eb) =>
eb.or([
@@ -145,9 +132,7 @@ export async function FindBMSChartOnHash(hash: string) {
return null;
}
return chartJoinedToDocument(
row as { song_legacy_id: number } & ChartJoinedRow,
) as MONGO_ChartDocument<"bms:7K" | "bms:14K">;
return ToChartDocument(row) as MONGO_ChartDocument<"bms:7K" | "bms:14K">;
}
/**
@@ -176,11 +161,7 @@ export async function FindBMSSieglindeRatedCharts(playtype: Playtypes["bms"]): P
const songs: Array<MONGO_SongDocument<"bms">> = [];
for (const row of rows) {
charts.push(
chartJoinedToDocument(
row as { song_legacy_id: number } & ChartJoinedRow,
) as MONGO_ChartDocument<"bms:7K" | "bms:14K">,
);
charts.push(ToChartDocument(row) as MONGO_ChartDocument<"bms:7K" | "bms:14K">);
songs.push(ToSongDocument(row) as MONGO_SongDocument<"bms">);
}
@@ -192,7 +173,7 @@ export async function FindBMSChartOnHashInGame(hash: string, v3Game: Game) {
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("chart.game", "=", v3Game)
.where((eb) =>
eb.or([
@@ -206,9 +187,7 @@ export async function FindBMSChartOnHashInGame(hash: string, v3Game: Game) {
return null;
}
return chartJoinedToDocument(
row as { song_legacy_id: number } & ChartJoinedRow,
) as MONGO_ChartDocument<"bms:7K" | "bms:14K">;
return ToChartDocument(row) as MONGO_ChartDocument<"bms:7K" | "bms:14K">;
}
/** All BMS charts matching MD5 or SHA256 in chart data. Used by global chart-hash search. */
@@ -218,7 +197,7 @@ export async function FindBMSChartsByHashMd5OrSha256(
const rows = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", "bms")
.where((eb) =>
eb.or([
@@ -228,9 +207,7 @@ export async function FindBMSChartsByHashMd5OrSha256(
)
.execute();
return rows.map((row) =>
chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow),
);
return rows.map(ToChartDocument);
}
/** All PMS charts matching MD5 or SHA256 in chart data. Used by global chart-hash search. */
@@ -240,7 +217,7 @@ export async function FindPMSChartsByHashMd5OrSha256(
const rows = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", "pms")
.where((eb) =>
eb.or([
@@ -250,9 +227,7 @@ export async function FindPMSChartsByHashMd5OrSha256(
)
.execute();
return rows.map((row) =>
chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow),
);
return rows.map(ToChartDocument);
}
/** All ITG Stamina charts matching hashGSv3. Used by global chart-hash search. */
@@ -260,14 +235,12 @@ export async function FindITGChartsByHashGSv3(hash: string): Promise<Array<MONGO
const rows = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("chart.game", "=", "itg-stamina" as Game)
.where(sql<boolean>`(chart.data::jsonb->>'hashGSv3') = ${hash}`)
.execute();
return rows.map((row) =>
chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow),
);
return rows.map(ToChartDocument);
}
/**
@@ -279,21 +252,17 @@ export async function FindBeatorajaChartOnHashSHA256(
const bmsRow = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", "bms")
.where(sql<boolean>`(chart.data::jsonb->>'hashSHA256') = ${hash}`)
.executeTakeFirst();
if (bmsRow) {
return chartJoinedToDocument(
bmsRow as { song_legacy_id: number } & ChartJoinedRow,
) as MONGO_ChartDocument<"bms:7K" | "bms:14K">;
return ToChartDocument(bmsRow) as MONGO_ChartDocument<"bms:7K" | "bms:14K">;
}
const pmsRow = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", "pms")
.where(sql<boolean>`(chart.data::jsonb->>'hashSHA256') = ${hash}`)
.executeTakeFirst();
@@ -302,9 +271,7 @@ export async function FindBeatorajaChartOnHashSHA256(
return null;
}
return chartJoinedToDocument(
pmsRow as { song_legacy_id: number } & ChartJoinedRow,
) as MONGO_ChartDocument<"pms:Controller" | "pms:Keyboard">;
return ToChartDocument(pmsRow) as MONGO_ChartDocument<"pms:Controller" | "pms:Keyboard">;
}
/**
@@ -316,7 +283,6 @@ export async function FindPopnChartOnHashSHA256(hash: string, playtype: Playtype
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", "popn")
.where("chart.game", "=", v3Game)
.where(sql<boolean>`(chart.data::jsonb->>'hashSHA256') = ${hash}`)
@@ -326,7 +292,7 @@ export async function FindPopnChartOnHashSHA256(hash: string, playtype: Playtype
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
/**
@@ -361,7 +327,6 @@ export async function FindChartOnInGameID(
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", game)
.where("chart.game", "=", v3Game)
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
@@ -372,7 +337,7 @@ export async function FindChartOnInGameID(
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
/**
@@ -393,7 +358,6 @@ export async function FindChartOnInGameIDPrimary(
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", game)
.where("chart.game", "=", v3Game)
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
@@ -405,7 +369,7 @@ export async function FindChartOnInGameIDPrimary(
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
/**
@@ -422,7 +386,6 @@ export async function FindIIDXChartOnInGameID(
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("chart.game", "=", v3Game)
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
.where(sql<SqlBool>`(chart.data->>'2dxtraSet') IS NULL`)
@@ -434,7 +397,7 @@ export async function FindIIDXChartOnInGameID(
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
/**
@@ -452,7 +415,6 @@ export async function FindIIDXChartOnInGameIDVersion(
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("chart.game", "=", v3Game)
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
.where(sql<SqlBool>`(chart.data->>'2dxtraSet') IS NULL`)
@@ -464,7 +426,7 @@ export async function FindIIDXChartOnInGameIDVersion(
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
/**
@@ -482,7 +444,7 @@ export async function FindChartOnInGameIDVersion<GPT extends GPTString = GPTStri
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", game)
.where("chart.game", "=", v3Game)
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
@@ -494,7 +456,7 @@ export async function FindChartOnInGameIDVersion<GPT extends GPTString = GPTStri
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
/**
@@ -515,7 +477,7 @@ export async function FindChartOnInGameStrIDPrimary(
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", game)
.where("chart.game", "=", v3Game)
.where(sql<boolean>`(chart.data::jsonb->>'inGameStrID') = ${inGameStrID}`)
@@ -527,7 +489,7 @@ export async function FindChartOnInGameStrIDPrimary(
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
/**
@@ -549,7 +511,7 @@ export async function FindChartOnInGameStrIDVersion<GPT extends GPTString = GPTS
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", game)
.where("chart.game", "=", v3Game)
.where(sql<boolean>`(chart.data::jsonb->>'inGameStrID') = ${inGameStrID}`)
@@ -561,7 +523,7 @@ export async function FindChartOnInGameStrIDVersion<GPT extends GPTString = GPTS
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
/**
@@ -571,7 +533,7 @@ export async function FindIIDXChartWith2DXtraHash(hash: string) {
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("chart.game", "=", "iidx-sp" as Game)
.where(sql<boolean>`(chart.data::jsonb->>'hashSHA256') = ${hash}`)
.executeTakeFirst();
@@ -580,7 +542,7 @@ export async function FindIIDXChartWith2DXtraHash(hash: string) {
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
const SDVX_INF_DIFFS = ["INF", "GRV", "HVN", "VVD", "XCD"] as const;
@@ -598,7 +560,7 @@ export async function FindSDVXChartOnInGameID(
let q = DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("chart.game", "=", "sdvx" as Game)
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
.where("chart.is_primary", "=", true);
@@ -614,7 +576,7 @@ export async function FindSDVXChartOnInGameID(
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
export async function FindSDVXChartOnInGameIDVersion(
@@ -625,7 +587,7 @@ export async function FindSDVXChartOnInGameIDVersion(
let q = DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("chart.game", "=", "sdvx" as Game)
.where(sql<boolean>`(chart.data::jsonb->>'inGameID')::int = ${inGameID}`)
.where(sql<boolean>`${sql.lit(String(version))} = ANY(chart.versions)`);
@@ -641,7 +603,7 @@ export async function FindSDVXChartOnInGameIDVersion(
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
export async function FindSDVXChartOnDFVersion(
@@ -652,7 +614,7 @@ export async function FindSDVXChartOnDFVersion(
let q = DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", "sdvx")
.where("song.legacy_id", "=", songID)
.where("chart.game", "=", "sdvx" as Game)
@@ -669,7 +631,7 @@ export async function FindSDVXChartOnDFVersion(
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
export async function FindChartOnSHA256(game: GameGroup, hash: string) {
@@ -680,7 +642,7 @@ export async function FindChartOnSHA256(game: GameGroup, hash: string) {
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", game)
.where(sql<boolean>`(chart.data::jsonb->>'hashSHA256') = ${hash}`)
.executeTakeFirst();
@@ -689,7 +651,7 @@ export async function FindChartOnSHA256(game: GameGroup, hash: string) {
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
export async function FindChartOnSHA256Playtype(game: GameGroup, hash: string, playtype: Playtype) {
@@ -702,7 +664,7 @@ export async function FindChartOnSHA256Playtype(game: GameGroup, hash: string, p
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", game)
.where("chart.game", "=", v3Game)
.where(sql<boolean>`(chart.data::jsonb->>'hashSHA256') = ${hash}`)
@@ -712,7 +674,7 @@ export async function FindChartOnSHA256Playtype(game: GameGroup, hash: string, p
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
/**
@@ -725,7 +687,7 @@ export async function FindUSCChartOnSHA1Playtype(hash: string, playtype: Playtyp
const row = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", "usc")
.where("chart.game", "=", v3Game)
.where(sql<boolean>`(chart.data::jsonb->>'hashSHA1') = ${hash}`)
@@ -735,7 +697,7 @@ export async function FindUSCChartOnSHA1Playtype(hash: string, playtype: Playtyp
return null;
}
return chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow);
return ToChartDocument(row);
}
/** All USC charts matching a SHA1 hash (any playtype). Used by global chart-hash search. */
@@ -743,14 +705,12 @@ export async function FindUSCChartsByHashSHA1(hash: string): Promise<Array<MONGO
const rows = await DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.select("song.legacy_id as song_legacy_id")
.where("song.game_group", "=", "usc")
.where(sql<boolean>`(chart.data::jsonb->>'hashSHA1') = ${hash}`)
.execute();
return rows.map((row) =>
chartJoinedToDocument(row as { song_legacy_id: number } & ChartJoinedRow),
);
return rows.map((row) => ToChartDocument(row));
}
/**

Some files were not shown because too many files have changed in this diff Show More