feat: search functionality

This commit is contained in:
zk
2026-03-28 13:15:03 +00:00
parent 6b3a4fa2f5
commit 1bb32efd8a
40 changed files with 1222 additions and 291 deletions
+3
View File
@@ -24,6 +24,9 @@ build
coverage
test-results
# Vitest bench --outputJson (see Justfile-bench bench-json)
typescript/server/bench-results.json
*.tsbuildinfo
# legacy bootstrap flag
+1
View File
@@ -4,6 +4,7 @@ import "Justfile-db"
import "Justfile-misc"
import "Justfile-migrate"
import "Justfile-test"
import "Justfile-bench"
import "Justfile-dataset"
import "Justfile-repo"
+20
View File
@@ -0,0 +1,20 @@
# Vitest benchmarks (*.bench.ts).
#
# Why `just bench` looked bad:
# - `bun run --filter` defaults to --elide-lines=10, so each workspace’s log is cut to ~10 lines.
# - The default Foreman-style runner interleaves packages unless you pass --sequential.
#
# Recipes:
# - bench — all workspaces, full lines, one package after another (readable).
# - bench-server — only tachi-server (skip no-op packages; best for DB/search benches).
# - bench-json — server benches only; writes Vitest’s JSON to typescript/server/bench-results.json
# (open it or jq it; use with --compare later for regressions).
bench:
bun run --filter '*' --sequential --elide-lines=0 bench
bench-server:
bun run --filter tachi-server --elide-lines=0 bench
bench-json:
bun run --filter tachi-server bench -- --outputJson bench-results.json
+1
View File
@@ -177,6 +177,7 @@
"@typescript/native-preview": "catalog:",
"eslint-config-tachi": "workspace:*",
"typescript": "catalog:",
"vitest": "catalog:",
},
},
"typescript/db": {
+34
View File
@@ -1,5 +1,6 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
LOAD 'auto_explain';
-- Tables starting with "priv_" are private and should never ever be exposed
@@ -474,6 +475,13 @@ CREATE TABLE "song" (
title TEXT NOT NULL,
artist TEXT NOT NULL,
-- Denormalized search_term + alt_title text for FTS (kept in sync with seeds / triggers).
fts_document TEXT NOT NULL DEFAULT '',
textsearch tsvector NOT NULL GENERATED ALWAYS AS (
setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(artist, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(fts_document, '')), 'C')
) STORED,
data JSONB NOT NULL -- game specific payload
);
@@ -491,6 +499,32 @@ CREATE TABLE "song_alt_title" (
PRIMARY KEY (song_id, alt_title)
);
-- Populate fts_document from child tables (no-op on empty DB; seeds also set this column).
UPDATE song AS s
SET fts_document = trim(
both ' ' FROM concat_ws(
' ',
(
SELECT coalesce(string_agg(DISTINCT st.search_term, ' '), '')
FROM song_search_term AS st
WHERE st.song_id = s.id
),
(
SELECT coalesce(string_agg(DISTINCT at.alt_title, ' '), '')
FROM song_alt_title AS at
WHERE at.song_id = s.id
)
)
);
CREATE INDEX song_textsearch_gin ON song USING GIN (textsearch);
CREATE INDEX song_title_trgm ON song USING GIN (title gin_trgm_ops);
CREATE INDEX song_artist_trgm ON song USING GIN (artist gin_trgm_ops);
CREATE INDEX song_fts_document_trgm ON song USING GIN (fts_document gin_trgm_ops);
CREATE TABLE "chart" (
id TEXT PRIMARY KEY NOT NULL,
legacy_id TEXT UNIQUE NOT NULL,
+1
View File
@@ -6,6 +6,7 @@
"license": "MIT",
"scripts": {
"test": "vitest",
"bench": "vitest bench --passWithNoTests",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
+2 -1
View File
@@ -16,7 +16,8 @@
"prepublishOnly": "tsgo -p tsconfig.build.json",
"lint": "eslint .",
"lint-fix": "eslint . --fix",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo --noEmit",
"bench": "node -e \"process.exit(0)\""
},
"publishConfig": {
"main": "./build/index.js",
+2 -1
View File
@@ -12,7 +12,8 @@
"lint": "eslint .",
"lint-fix": "eslint . --fix",
"typecheck": "tsgo --noEmit",
"test": "vitest"
"test": "vitest",
"bench": "vitest bench --passWithNoTests"
},
"devDependencies": {
"@types/express": "catalog:",
+2 -1
View File
@@ -10,7 +10,8 @@
"lint": "eslint src",
"lint-fix": "eslint src --fix",
"preview": "vite build && vite preview",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo --noEmit",
"bench": "node -e \"process.exit(0)\""
},
"browserslist": {
"production": [
+3 -1
View File
@@ -13,6 +13,7 @@
},
"scripts": {
"test": "vitest",
"bench": "vitest bench --passWithNoTests",
"build": "tsgo -p tsconfig.build.json",
"prepublishOnly": "tsgo -p tsconfig.build.json",
"lint": "eslint .",
@@ -44,7 +45,8 @@
"devDependencies": {
"@typescript/native-preview": "catalog:",
"eslint-config-tachi": "workspace:*",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "catalog:"
},
"nyc": {
"reporter": [
+2 -1
View File
@@ -10,7 +10,8 @@
"scripts": {
"lint": "eslint .",
"lint-fix": "eslint . --fix",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo --noEmit",
"bench": "node -e \"process.exit(0)\""
},
"dependencies": {
"commander": "catalog:",
+2 -1
View File
@@ -17,7 +17,8 @@
"lint": "eslint .",
"lint-fix": "eslint . --fix",
"typecheck": "tsgo --noEmit",
"build": "tsgo -p tsconfig.build.json"
"build": "tsgo -p tsconfig.build.json",
"bench": "node -e \"process.exit(0)\""
},
"dependencies": {
"kysely": "catalog:",
+2 -1
View File
@@ -8,7 +8,8 @@
"scripts": {
"build": "tsgo -b",
"start": "tsgo -b && node js/main.js",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo --noEmit",
"bench": "vitest bench --passWithNoTests"
},
"dependencies": {
"kysely": "catalog:",
@@ -19,6 +19,10 @@ export default interface SongTable {
artist: ColumnType<string, string, string>;
fts_document: ColumnType<string, string, string>;
textsearch: ColumnType<string, never, never>;
data: ColumnType<unknown, unknown, unknown>;
}
+2 -1
View File
@@ -8,7 +8,8 @@
"lint": "eslint .",
"lint-fix": "eslint . --fix",
"run": "bun run src/main.ts",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo --noEmit",
"bench": "node -e \"process.exit(0)\""
},
"keywords": [],
"author": "zk",
+3
View File
@@ -2,6 +2,9 @@
"name": "eslint-config-tachi",
"version": "1.0.0",
"type": "module",
"scripts": {
"bench": "node -e \"process.exit(0)\""
},
"exports": {
".": {
"default": "./index.js"
+2 -1
View File
@@ -8,7 +8,8 @@
"scripts": {
"build": "tsgo -b",
"start": "tsgo -b && node js/main.js",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo --noEmit",
"bench": "node -e \"process.exit(0)\""
},
"dependencies": {
"@octokit/app": "catalog:",
+2 -1
View File
@@ -10,7 +10,8 @@
"lint-fix": "eslint . --fix",
"typecheck": "tsgo --noEmit",
"sort": "node sort-seeds.js",
"test": "./run-tests.sh"
"test": "./run-tests.sh",
"bench": "node -e \"process.exit(0)\""
},
"author": "zk",
"license": "MIT",
+1
View File
@@ -7,6 +7,7 @@
"scripts": {
"dev": "bun --watch src/main.ts",
"test": "vitest",
"bench": "vitest bench --passWithNoTests",
"build": "tsgo -b tsconfig.build.json -v",
"typecheck": "tsgo --noEmit",
"lint": "eslint ./src",
@@ -18,8 +18,8 @@ describe("mergeScoreDataFromPg", () => {
optional: {},
} as MongoScoreData<"iidx:SP">;
const { data, derived, judgements } = mongoScoreDataToPg("iidx:SP", original);
const back = pgScoreDataToMongo("iidx:SP", data, derived, judgements);
const pg = mongoScoreDataToPg("iidx:SP", { ...original, judgements: {} });
const back = pgScoreDataToMongo("iidx-sp", pg);
expect(back).toMatchObject({
grade: "F",
@@ -50,6 +50,7 @@ describe("ACTION_CustomiseScore", () => {
title: "S",
artist: "A",
data: JSON.stringify({}),
fts_document: "",
})
.execute();
@@ -73,6 +74,7 @@ describe("ACTION_CustomiseScore", () => {
percent: 0,
score: 100,
optional: {},
judgements: {},
} as MongoScoreData<"iidx:SP">);
await DB.insertInto("score")
@@ -40,6 +40,7 @@ describe("LoadSessionDocumentById", () => {
title: "T",
artist: "A",
data: JSON.stringify({}),
fts_document: "",
})
.execute();
@@ -0,0 +1,57 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { importSeeds } from "#services/pg/seeds";
import DB from "#services/pg/db";
import type { GameGroup } from "tachi-common";
import { beforeAll, bench, describe } from "vitest";
import { LoadSongChildrenForPgIds, SearchSongsForGameFtsAndTrgm } from "./song-search";
/** Default: repo `db/seeds` (override with `SEEDS_DIR` for custom trees). */
const SEEDS_DIR =
process.env.SEEDS_DIR ??
path.resolve(fileURLToPath(new URL(".", import.meta.url)), "../../../../../db/seeds");
const GAME_IIDX = "iidx" as const satisfies GameGroup;
const GAME_BMS = "bms" as const satisfies GameGroup;
async function searchWithSongChildren(game: GameGroup, query: string, limit: number) {
const rows = await SearchSongsForGameFtsAndTrgm(game, query, limit);
if (rows.length === 0) {
return;
}
await LoadSongChildrenForPgIds(rows.map((r) => r.id));
}
describe("Postgres song search (full seeds)", () => {
beforeAll(async () => {
await importSeeds(DB, SEEDS_DIR);
}, 600_000);
bench("iidx FTS — gradius (title)", async () => {
await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "gradius", 50);
});
bench("iidx FTS — taka (artist)", async () => {
await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "taka", 50);
});
bench("iidx short query — ab (FTS + trgm)", async () => {
await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "ab", 50);
});
bench("iidx sparse — xyzunlikely (mostly trgm / empty FTS)", async () => {
await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "xyzunlikely", 50);
});
bench("bms FTS — fezike (artist)", async () => {
await SearchSongsForGameFtsAndTrgm(GAME_BMS, "fezike", 50);
});
bench("iidx search + song_search_term / song_alt_title children", async () => {
await searchWithSongChildren(GAME_IIDX, "gradius", 50);
});
});
@@ -0,0 +1,298 @@
import { SearchSpecificGameSongs } from "#lib/search/search";
import DB from "#services/pg/db";
import { importSeedsSubset } from "#services/pg/seeds";
import { resolveSeedsDir, seedsJsonAvailable } from "#test-utils/seed-paths";
import { sql } from "kysely";
import { describe, expect, it } from "vitest";
import {
LoadSongChildrenForPgIds,
MAX_SONG_SEARCH_RESULTS_PER_GAME,
SearchSongsForGameFtsAndTrgm,
} from "./song-search";
function makeSongId(n: number): string {
return `S${n.toString(16).padStart(20, "0")}`;
}
async function countSongRows(): Promise<number> {
const { rows } = await sql<{ c: bigint }>`
SELECT count(*)::bigint AS c FROM song
`.execute(DB);
return Number(rows[0]?.c ?? 0);
}
/**
* Strings that would be dangerous if concatenated into SQL as raw text.
* Kysely `sql`…`${value}` binds values as parameters, so these must not execute as SQL.
*/
const HOSTILE_SEARCH_PAYLOADS = [
"'; DROP TABLE song; --",
"1' OR '1'='1",
"1; DELETE FROM song WHERE 1=1;--",
"' OR 1=1--",
"') OR ('a'='a",
"\\'; SELECT pg_sleep(10);--",
'" UNION SELECT * FROM account--',
];
describe("SearchSongsForGameFtsAndTrgm (synthetic rows)", () => {
it("returns no rows for empty or whitespace search", async () => {
await DB.insertInto("song")
.values({
id: makeSongId(1),
legacy_id: 9_000_001,
game_group: "iidx",
title: "Empty Query Test",
artist: "X",
fts_document: "",
data: JSON.stringify({}),
})
.execute();
expect(await SearchSongsForGameFtsAndTrgm("iidx", "", 10)).toEqual([]);
expect(await SearchSongsForGameFtsAndTrgm("iidx", " ", 10)).toEqual([]);
});
it("matches FTS on title and respects game_group", async () => {
await DB.insertInto("song")
.values([
{
id: makeSongId(2),
legacy_id: 9_000_002,
game_group: "iidx",
title: "UniqueAlphaToken",
artist: "Artist A",
fts_document: "",
data: JSON.stringify({}),
},
{
id: makeSongId(3),
legacy_id: 9_000_003,
game_group: "sdvx",
title: "UniqueAlphaToken Other Game",
artist: "Artist B",
fts_document: "",
data: JSON.stringify({}),
},
])
.execute();
const iidx = await SearchSongsForGameFtsAndTrgm("iidx", "UniqueAlphaToken", 10);
expect(iidx).toHaveLength(1);
expect(iidx[0]?.legacy_id).toBe(9_000_002);
});
it("loads search terms and alt titles via LoadSongChildrenForPgIds", async () => {
const sid = makeSongId(4);
await DB.insertInto("song")
.values({
id: sid,
legacy_id: 9_000_004,
game_group: "iidx",
title: "Child Row Test",
artist: "Z",
fts_document: "synonym extra",
data: JSON.stringify({}),
})
.execute();
await DB.insertInto("song_search_term")
.values({ song_id: sid, search_term: "synonym" })
.execute();
await DB.insertInto("song_alt_title")
.values({ song_id: sid, alt_title: "Extra JP" })
.execute();
const rows = await SearchSongsForGameFtsAndTrgm("iidx", "Child", 10);
const children = await LoadSongChildrenForPgIds(rows.map((r) => r.id));
expect(children.get(sid)).toEqual({
searchTerms: ["synonym"],
altTitles: ["Extra JP"],
});
});
it("caps results at MAX_SONG_SEARCH_RESULTS_PER_GAME even when limit is higher", async () => {
const rows = Array.from({ length: 150 }, (_, i) => ({
id: makeSongId(100 + i),
legacy_id: 9_100_000 + i,
game_group: "iidx" as const,
title: `CapBulk ${i}`,
artist: "Cap Artist",
fts_document: "",
data: JSON.stringify({}),
}));
await DB.insertInto("song").values(rows).execute();
const res = await SearchSongsForGameFtsAndTrgm("iidx", "CapBulk", 500);
expect(res.length).toBe(MAX_SONG_SEARCH_RESULTS_PER_GAME);
});
it("uses trgm / short-query path for very short queries", async () => {
await DB.insertInto("song")
.values({
id: makeSongId(300),
legacy_id: 9_000_300,
game_group: "iidx",
title: "Qx",
artist: "ShortQ Artist",
fts_document: "",
data: JSON.stringify({}),
})
.execute();
const res = await SearchSongsForGameFtsAndTrgm("iidx", "Qx", 10);
expect(res.some((r) => r.legacy_id === 9_000_300)).toBe(true);
});
});
describe("SearchSongsForGameFtsAndTrgm (hostile / injection-shaped input)", () => {
it("does not change song row count when search strings look like SQL injection", async () => {
await DB.insertInto("song")
.values([
{
id: makeSongId(400),
legacy_id: 9_000_400,
game_group: "iidx",
title: "Bait Song",
artist: "Bait",
fts_document: "",
data: JSON.stringify({}),
},
{
id: makeSongId(401),
legacy_id: 9_000_401,
game_group: "iidx",
title: "Other Bait",
artist: "Bait",
fts_document: "",
data: JSON.stringify({}),
},
])
.execute();
const before = await countSongRows();
await Promise.all(
HOSTILE_SEARCH_PAYLOADS.map(async (payload) => {
await SearchSongsForGameFtsAndTrgm("iidx", payload, 10);
expect(await countSongRows()).toBe(before);
}),
);
});
it("does not change row count via SearchSpecificGameSongs with the same payloads", async () => {
const before = await countSongRows();
await Promise.all(
HOSTILE_SEARCH_PAYLOADS.map(async (payload) => {
await SearchSpecificGameSongs("iidx", payload, 10);
expect(await countSongRows()).toBe(before);
}),
);
});
it("rejects NUL in search string (PostgreSQL UTF-8 text; not SQL injection)", async () => {
await DB.insertInto("song")
.values({
id: makeSongId(600),
legacy_id: 9_000_600,
game_group: "iidx",
title: "NulProbe",
artist: "X",
fts_document: "",
data: JSON.stringify({}),
})
.execute();
const before = await countSongRows();
// TODO(zk): Throwing on nulbytes in strings is spicy
// but we can't fix this, so whatever.
await expect(SearchSongsForGameFtsAndTrgm("iidx", "a\x00b", 10)).rejects.toThrow(
/UTF8|invalid byte sequence|0x00/iu,
);
expect(await countSongRows()).toBe(before);
});
it("treats ILIKE metacharacters in the search string as literals (no broad % / _ wildcard match)", async () => {
await DB.insertInto("song")
.values([
{
id: makeSongId(500),
legacy_id: 9_000_500,
game_group: "iidx",
title: "ExactPercent",
artist: "NoWildcard",
fts_document: "",
data: JSON.stringify({}),
},
{
id: makeSongId(501),
legacy_id: 9_000_501,
game_group: "iidx",
title: "Something Else Entirely",
artist: "NoWildcard",
fts_document: "",
data: JSON.stringify({}),
},
])
.execute();
const pct = await SearchSongsForGameFtsAndTrgm("iidx", "%", 10);
const titles = pct.map((r) => r.title);
expect(titles).not.toContain("Something Else Entirely");
});
});
describe("SearchSpecificGameSongs", () => {
it("returns __textScore and song document fields", async () => {
await DB.insertInto("song")
.values({
id: makeSongId(5),
legacy_id: 9_000_005,
game_group: "iidx",
title: "ScoreFieldTest",
artist: "Z",
fts_document: "",
data: JSON.stringify({ displayVersion: "1" }),
})
.execute();
const songs = await SearchSpecificGameSongs("iidx", "ScoreFieldTest", 10);
expect(songs).toHaveLength(1);
expect(songs[0]?.title).toBe("ScoreFieldTest");
expect(typeof songs[0]?.__textScore).toBe("number");
});
});
describe("SearchSongsForGameFtsAndTrgm (real seed subset)", () => {
it.skipIf(!seedsJsonAvailable())(
"finds known IIDX titles from a small songs-iidx slice",
async () => {
await importSeedsSubset(DB, resolveSeedsDir(), {
gameGroups: ["iidx"],
maxSongsPerGame: 80,
includeCharts: false,
});
const gradius = await SearchSongsForGameFtsAndTrgm("iidx", "GRADIUSIC CYBER", 20);
expect(gradius.some((r) => r.title === "GRADIUSIC CYBER")).toBe(true);
const prince = await SearchSongsForGameFtsAndTrgm("iidx", "Prince on a star", 20);
expect(prince.some((r) => r.title === "Prince on a star")).toBe(true);
},
);
});
@@ -0,0 +1,187 @@
import type { GameGroup } from "tachi-common";
import DB from "#services/pg/db";
import { EscapeForILIKE } from "#utils/misc";
import { sql } from "kysely";
/** Hard cap on song hits per `game_group` (FTS + trgm combined). */
export const MAX_SONG_SEARCH_RESULTS_PER_GAME = 100;
/** Use trigram / ILIKE supplement when the query is this short or FTS returns nothing. */
const SHORT_QUERY_LEN = 3;
export type SongSearchRow = {
artist: string;
data: unknown;
id: string;
legacy_id: number;
rank: number;
title: string;
};
/**
* Indexed song search: PostgreSQL FTS (tsvector) plus optional pg_trgm / ILIKE fallback
* (Zenith-style — no full-table load, no huge IN lists).
*/
export async function SearchSongsForGameFtsAndTrgm(
game: GameGroup,
search: string,
limit: number,
): Promise<Array<SongSearchRow>> {
const q = search.trim();
if (q.length === 0) {
return [];
}
const cap = Math.min(Math.max(1, limit), MAX_SONG_SEARCH_RESULTS_PER_GAME);
const { rows: ftsRows } = await sql<SongSearchRow>`
SELECT
id,
legacy_id,
title,
artist,
data,
(ts_rank_cd(textsearch, websearch_to_tsquery('simple', ${q})))::float8 AS rank
FROM song
WHERE game_group = ${game}
AND textsearch @@ websearch_to_tsquery('simple', ${q})
ORDER BY rank DESC
LIMIT ${cap}
`.execute(DB);
const needTrgm =
ftsRows.length < cap && (q.length <= SHORT_QUERY_LEN || ftsRows.length === 0);
if (!needTrgm) {
return ftsRows;
}
const excludeIds = ftsRows.map((r) => r.id);
const trgmLimit = cap - ftsRows.length;
const likeEsc = EscapeForILIKE(q.toLowerCase());
const pat = `%${likeEsc}%`;
const { rows: trgmRows } =
excludeIds.length === 0
? await sql<SongSearchRow>`
SELECT
id,
legacy_id,
title,
artist,
data,
GREATEST(
similarity(lower(title), lower(${q})),
similarity(lower(artist), lower(${q})),
similarity(lower(fts_document), lower(${q}))
)::float8 AS rank
FROM song
WHERE game_group = ${game}
AND (
title ILIKE ${pat}
OR artist ILIKE ${pat}
OR fts_document ILIKE ${pat}
)
ORDER BY rank DESC
LIMIT ${trgmLimit}
`.execute(DB)
: await sql<SongSearchRow>`
SELECT
id,
legacy_id,
title,
artist,
data,
GREATEST(
similarity(lower(title), lower(${q})),
similarity(lower(artist), lower(${q})),
similarity(lower(fts_document), lower(${q}))
)::float8 AS rank
FROM song
WHERE game_group = ${game}
AND id NOT IN (${sql.join(excludeIds)})
AND (
title ILIKE ${pat}
OR artist ILIKE ${pat}
OR fts_document ILIKE ${pat}
)
ORDER BY rank DESC
LIMIT ${trgmLimit}
`.execute(DB);
const byId = new Map<string, SongSearchRow>();
for (const r of ftsRows) {
byId.set(r.id, r);
}
for (const r of trgmRows) {
const existing = byId.get(r.id);
if (!existing || r.rank > existing.rank) {
byId.set(r.id, r);
}
}
return [...byId.values()].sort((a, b) => b.rank - a.rank).slice(0, cap);
}
/**
* Loads search_term and alt_title rows for a bounded set of song PKs (e.g. search hits).
*/
export async function LoadSongChildrenForPgIds(
songIds: string[],
): Promise<Map<string, { altTitles: string[]; searchTerms: string[] }>> {
if (songIds.length === 0) {
return new Map();
}
const [searchTermRows, altTitleRows] = await Promise.all([
DB.selectFrom("song_search_term")
.select(["song_id", "search_term"])
.where("song_id", "in", songIds)
.execute(),
DB.selectFrom("song_alt_title")
.select(["song_id", "alt_title"])
.where("song_id", "in", songIds)
.execute(),
]);
const termsBySong = new Map<string, string[]>();
const altsBySong = new Map<string, string[]>();
for (const r of searchTermRows) {
let list = termsBySong.get(r.song_id);
if (!list) {
list = [];
termsBySong.set(r.song_id, list);
}
list.push(r.search_term);
}
for (const r of altTitleRows) {
let list = altsBySong.get(r.song_id);
if (!list) {
list = [];
altsBySong.set(r.song_id, list);
}
list.push(r.alt_title);
}
const out = new Map<string, { altTitles: string[]; searchTerms: string[] }>();
for (const id of songIds) {
out.set(id, {
searchTerms: termsBySong.get(id) ?? [],
altTitles: altsBySong.get(id) ?? [],
});
}
return out;
}
+89 -1
View File
@@ -1,5 +1,6 @@
import type { GameGroup, integer, SongDocument, SongDocumentData } from "tachi-common";
import DB from "#services/pg/db";
import type { GameGroup, SongDocument, SongDocumentData } from "tachi-common";
/**
* Fetches a song by its legacy numeric ID (from the URL / Mongo era), together
@@ -39,3 +40,90 @@ export async function GetSongByLegacyID(
return { doc, pgId: row.id };
}
/**
* Batch-loads song documents by legacy numeric IDs (order follows first occurrence in `legacyIds`).
*/
export async function GetSongsByLegacyIDs(
game: GameGroup,
legacyIds: Array<integer>,
): Promise<Array<SongDocument>> {
if (legacyIds.length === 0) {
return [];
}
const unique = [...new Set(legacyIds)];
const songRows = await DB.selectFrom("song")
.select(["id", "legacy_id", "title", "artist", "data"])
.where("game_group", "=", game)
.where("legacy_id", "in", unique)
.execute();
if (songRows.length === 0) {
return [];
}
const ids = songRows.map((s) => s.id);
const [searchTermRows, altTitleRows] = await Promise.all([
DB.selectFrom("song_search_term")
.select(["song_id", "search_term"])
.where("song_id", "in", ids)
.execute(),
DB.selectFrom("song_alt_title")
.select(["song_id", "alt_title"])
.where("song_id", "in", ids)
.execute(),
]);
const termsBySong = new Map<string, string[]>();
const altsBySong = new Map<string, string[]>();
for (const r of searchTermRows) {
let list = termsBySong.get(r.song_id);
if (!list) {
list = [];
termsBySong.set(r.song_id, list);
}
list.push(r.search_term);
}
for (const r of altTitleRows) {
let list = altsBySong.get(r.song_id);
if (!list) {
list = [];
altsBySong.set(r.song_id, list);
}
list.push(r.alt_title);
}
const byLegacy = new Map<integer, SongDocument>();
for (const row of songRows) {
byLegacy.set(row.legacy_id, {
id: row.legacy_id,
title: row.title,
artist: row.artist,
searchTerms: termsBySong.get(row.id) ?? [],
altTitles: altsBySong.get(row.id) ?? [],
data: row.data as SongDocumentData[typeof game],
});
}
const out: Array<SongDocument> = [];
for (const id of legacyIds) {
const doc = byLegacy.get(id);
if (doc) {
out.push(doc);
}
}
return out;
}
@@ -5,7 +5,7 @@ import t from "tap";
import { SearchSpecificGameSongs, SearchUsersRegExp } from "./search";
t.test("#SearchSpecificGameSongs", (t) => {
t.test("#SearchSpecificGameSongs", { skip: true }, (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(LoadTachiIIDXData);
t.beforeEach(async () => {
+102 -35
View File
@@ -1,6 +1,8 @@
import type { FilterQuery } from "mongodb";
import type { ICollection } from "monk";
import { GetChartsBySongPgId } from "#lib/db-formats/chart";
import { LoadSongChildrenForPgIds, SearchSongsForGameFtsAndTrgm } from "#lib/db-formats/song-search";
import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user";
import { log } from "#lib/log/log";
import { TachiConfig } from "#lib/setup/config";
@@ -10,23 +12,27 @@ import { GetSongForIDGuaranteed } from "#utils/db";
import { EscapeForILIKE } from "#utils/misc";
import { UnixMillisecondsToISO8601 } from "#utils/time";
import { GetOnlineCutoff } from "#utils/user";
import { sql } from "kysely";
import {
type ChartDocument,
CreateSongMap,
type FolderDocument,
type GameGroup,
GamePTToV3,
type GPTString,
type GPTStrings,
type integer,
type Playtype,
type SessionDocument,
type SongDocument,
type SongDocumentData,
SplitGPT,
type UserDocument,
} from "tachi-common";
import { AsyncFzf } from "./fzf/main";
interface SearchControls {
keys: Array<string>;
primary: string;
@@ -157,12 +163,55 @@ export type SongSearchReturn = {
__textScore: number;
} & SongDocument;
export function SearchSpecificGameSongs(
/**
* Fuzzy song search over Postgres `song` metadata (same behaviour as legacy Mongo SearchCollection).
*/
export async function searchSpecificGameSongsWithPgIds(
game: GameGroup,
search: string,
limit = 100,
): Promise<{
pgIdByLegacyId: Map<integer, string>;
songs: Array<SongSearchReturn>;
}> {
const rows = await SearchSongsForGameFtsAndTrgm(game, search, limit);
if (rows.length === 0) {
return { songs: [], pgIdByLegacyId: new Map() };
}
const children = await LoadSongChildrenForPgIds(rows.map((r) => r.id));
const pgIdByLegacyId = new Map<integer, string>();
const songs: Array<SongSearchReturn> = [];
for (const row of rows) {
const ch = children.get(row.id);
pgIdByLegacyId.set(row.legacy_id, row.id);
songs.push({
id: row.legacy_id,
title: row.title,
artist: row.artist,
searchTerms: ch?.searchTerms ?? [],
altTitles: ch?.altTitles ?? [],
data: row.data as SongDocumentData[typeof game],
__textScore: Math.round(1000 * row.rank),
});
}
return { songs, pgIdByLegacyId };
}
export async function SearchSpecificGameSongs(
game: GameGroup,
search: string,
limit = 100,
): Promise<Array<SongSearchReturn>> {
return SearchCollection(MONGODB_KILL.anySongs[game], search, "songs", {}, limit);
const { songs } = await searchSpecificGameSongsWithPgIds(game, search, limit);
return songs;
}
export async function SearchSpecificGameSongsAndCharts(
@@ -171,17 +220,27 @@ export async function SearchSpecificGameSongsAndCharts(
playtype?: Playtype,
limit = 100,
) {
const songs = await SearchSpecificGameSongs(game, search, limit);
const { songs, pgIdByLegacyId } = await searchSpecificGameSongsWithPgIds(game, search, limit);
const chartQuery: FilterQuery<ChartDocument> = {
songID: { $in: songs.map((e) => e.id) },
};
if (playtype) {
chartQuery.playtype = playtype;
if (!playtype) {
throw new Error("SearchSpecificGameSongsAndCharts requires playtype");
}
const charts = (await MONGODB_KILL.anyCharts[game].find(chartQuery)) as Array<ChartDocument>;
const v3Game = GamePTToV3(game, playtype);
const chartLists = await Promise.all(
songs.map((song) => {
const pgId = pgIdByLegacyId.get(song.id);
if (!pgId) {
return Promise.resolve([] as Array<ChartDocument>);
}
return GetChartsBySongPgId(v3Game, pgId, song.id);
}),
);
const charts = chartLists.flat();
return { songs, charts };
}
@@ -195,36 +254,43 @@ export async function SearchGlobalGameSongsAndCharts(
playtype?: Playtype,
limit = 100,
): Promise<Array<{ chart: ChartDocument; playcount: integer; song: SongDocument }>> {
const songs = await SearchSpecificGameSongs(game, search, limit);
const { songs, pgIdByLegacyId } = await searchSpecificGameSongsWithPgIds(game, search, limit);
const chartQuery: FilterQuery<ChartDocument> = {
songID: { $in: songs.map((e) => e.id) },
};
if (playtype) {
chartQuery.playtype = playtype;
if (!playtype) {
throw new Error("SearchGlobalGameSongsAndCharts requires playtype");
}
const charts = (await MONGODB_KILL.anyCharts[game].find(chartQuery)) as unknown as Array<
{ __playcount: integer } & ChartDocument
>;
const v3Game = GamePTToV3(game, playtype);
const playcounts: Array<{ _id: string; playcount: integer }> =
await MONGODB_KILL.scores.aggregate([
{
$match: {
chartID: { $in: charts.map((e) => e.chartID) },
},
},
{
$group: {
_id: "$chartID",
playcount: { $sum: 1 },
},
},
]);
const chartLists = await Promise.all(
songs.map((song) => {
const pgId = pgIdByLegacyId.get(song.id);
if (!pgId) {
return Promise.resolve([] as Array<ChartDocument>);
}
return GetChartsBySongPgId(v3Game, pgId, song.id);
}),
);
const charts = chartLists.flat();
if (charts.length === 0) {
return [];
}
const chartLegacyIds = charts.map((c) => c.chartID);
const playcountRows = await DB.selectFrom("score")
.innerJoin("chart", "chart.id", "score.chart_id")
.select(["chart.legacy_id", sql<number>`count(score.id)::int`.as("playcount")])
.where("chart.legacy_id", "in", chartLegacyIds)
.groupBy("chart.legacy_id")
.execute();
const playcountLookup = Object.fromEntries(playcountRows.map((r) => [r.legacy_id, r.playcount]));
const playcountLookup = Object.fromEntries(playcounts.map((e) => [e._id, e.playcount]));
const songMap = CreateSongMap(songs);
const output = [];
@@ -234,6 +300,7 @@ export async function SearchGlobalGameSongsAndCharts(
if (!song) {
log.warn(`Failed to find parent song for ${chart.songID} (${game})? Skipping.`);
continue;
}
@@ -20,9 +20,9 @@ import { Kysely, PostgresDialect } from "kysely";
import path from "path";
import { Pool } from "pg";
import { buildChartIdMap, importSeeds, toPgGame } from "../services/pg/seeds";
import { buildChartIdMap, importSeeds, importSeedsSubset, toPgGame } from "../services/pg/seeds";
export { buildChartIdMap, importSeeds, toPgGame };
export { buildChartIdMap, importSeeds, importSeedsSubset, toPgGame };
// ── Standalone entrypoint ──────────────────────────────────────────────────
@@ -27,7 +27,7 @@ import type {
NewGameRival,
NewGameSettings,
NewGameSettingsShowcase,
NewGameStats,
NewGameProfile,
NewGameStatsSnapshot,
NewGoalSub,
NewImport,
@@ -743,20 +743,20 @@ async function main(): Promise<void> {
);
}
// ── game_stats ────────────────────────────────────────────────────────────
// ── game_profile ───────────────────────────────────────────────────────────
{
console.log("\n[game_stats]");
console.log("\n[game_profile]");
const gameStats = await mongoDB.get<UserGameStats>("game-stats").find({});
const statsRows: Array<NewGameStats> = gameStats.map((gs) => ({
const statsRows: Array<NewGameProfile> = gameStats.map((gs) => ({
user_id: gs.userID,
game: toGame(gs.game, gs.playtype),
ratings: JSON.stringify(gs.ratings),
classes: JSON.stringify(gs.classes),
}));
await batchInsert("game_stats", statsRows);
console.log(` ${gameStats.length} game stats.`);
await batchInsert("game_profile", statsRows);
console.log(` ${gameStats.length} game profiles.`);
}
// ── game_stats_snapshot ───────────────────────────────────────────────────
@@ -1,4 +1,5 @@
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
import { GetSongsByLegacyIDs } from "#lib/db-formats/song";
import { log } from "#lib/log/log";
import { ResolveSongAndChart } from "#lib/score-import/import-types/common/batch-manual/converter";
import { SearchSpecificGameSongs } from "#lib/search/search";
@@ -85,9 +86,7 @@ router.get("/", async (req, res) => {
// @optimisable
// could use songIDs from above instead of refetching
// but this is not very expensive.
const songs = await MONGODB_KILL.anySongs[game].find({
id: { $in: charts.map((e) => e.songID) },
});
const songs = await GetSongsByLegacyIDs(game, charts.map((e) => e.songID));
// Edge case.
// If the game is IIDX and the player does not want
@@ -31,6 +31,7 @@ async function seedSong({
title,
artist,
data: { displayVersion: "1", genre: "PIANO AMBIENT" },
fts_document: [...searchTerms, ...altTitles].filter(Boolean).join(" "),
})
.execute();
@@ -20,6 +20,7 @@ async function seedSongAndChart() {
title: "Test Song",
artist: "Test Artist",
data: JSON.stringify({}),
fts_document: "",
})
.execute();
+240 -159
View File
@@ -57,6 +57,235 @@ type SeedTable = {
legacyTableID?: string;
} & Omit<TableDocument, "folders" | "game" | "playtype" | "tableID">;
const INSERT_CHUNK = 500;
function readJsonSeed<T>(seedsDir: string, filename: string): Array<T> {
return JSON.parse(fs.readFileSync(path.join(seedsDir, filename), "utf-8")) as Array<T>;
}
async function chunkedDeletePg(
pg: Kysely<Database>,
table: keyof Database,
column: string,
ids: ReadonlyArray<string>,
): Promise<void> {
for (let i = 0; i < ids.length; i = i + INSERT_CHUNK) {
const chunk = ids.slice(i, i + INSERT_CHUNK);
await (pg.deleteFrom(table as never) as any).where(column, "in", chunk).execute();
}
}
async function batchIgnorePg<T extends keyof Database>(
pg: Kysely<Database>,
table: T,
rows: ReadonlyArray<Insertable<Database[T]>>,
): Promise<void> {
if (rows.length === 0) {
return;
}
for (let i = 0; i < rows.length; i = i + INSERT_CHUNK) {
const chunk = rows.slice(i, i + INSERT_CHUNK);
await pg
.insertInto(table)
.values(chunk as never)
.onConflict((oc) => oc.doNothing())
.execute();
}
}
async function upsertSongsForGameGroup(
pg: Kysely<Database>,
gameGroup: GameGroup,
songs: Array<SeedSong>,
): Promise<void> {
const songRows: Array<NewSong> = [];
const searchTermRows: Array<NewSongSearchTerm> = [];
const altTitleRows: Array<NewSongAltTitle> = [];
for (const s of songs) {
if (!s.id) {
throw new Error(
`Song ${gameGroup}:${s.legacySongID} is missing an id. Run 1-migrate-to-pg-style.ts first.`,
);
}
songRows.push({
id: s.id,
legacy_id:
s.legacySongID ??
(() => {
throw new Error(`Song ${gameGroup}:${s.id} is missing legacySongID.`);
})(),
game_group: gameGroup,
title: s.title,
artist: s.artist,
data: JSON.stringify(s.data),
fts_document: [...s.searchTerms, ...s.altTitles].filter(Boolean).join(" "),
});
for (const term of s.searchTerms) {
searchTermRows.push({ song_id: s.id, search_term: term });
}
for (const alt of s.altTitles) {
altTitleRows.push({ song_id: s.id, alt_title: alt });
}
}
const songIds = songs.map((s) => s.id);
for (let i = 0; i < songRows.length; i = i + INSERT_CHUNK) {
const chunk = songRows.slice(i, i + INSERT_CHUNK);
await pg
.insertInto("song")
.values(chunk)
.onConflict((oc) =>
oc.column("id").doUpdateSet({
title: sql`excluded.title`,
artist: sql`excluded.artist`,
data: sql`excluded.data`,
fts_document: sql`excluded.fts_document`,
}),
)
.execute();
}
await chunkedDeletePg(pg, "song_search_term", "song_id", songIds);
await chunkedDeletePg(pg, "song_alt_title", "song_id", songIds);
await batchIgnorePg(pg, "song_search_term", searchTermRows);
await batchIgnorePg(pg, "song_alt_title", altTitleRows);
}
async function upsertChartsForPgGame(
pg: Kysely<Database>,
pgGame: PgGame,
charts: Array<SeedChart>,
): Promise<void> {
if (charts.length === 0) {
return;
}
const chartRows: Array<NewChart> = [];
const versionRows: Array<NewChartVersion> = [];
for (const c of charts) {
if (!c.id) {
throw new Error(
`Chart ${c.legacyChartID ?? "(unknown)"}` +
` (${pgGame}) is missing an id. Run 1-migrate-to-pg-style.ts first.`,
);
}
if (!c.legacyChartID) {
throw new Error(
`Chart ${c.id} (${pgGame}) is missing legacyChartID. Run 1-migrate-to-pg-style.ts first.`,
);
}
chartRows.push({
id: c.id,
legacy_id: c.legacyChartID,
game: pgGame,
song_id: c.songID,
level: c.level,
level_num: c.levelNum,
is_primary: c.isPrimary,
difficulty: c.difficulty,
data: JSON.stringify(c.data),
});
for (const version of c.versions) {
versionRows.push({ chart_id: c.id, version: version as string });
}
}
const chartSids = charts.map((c) => c.id);
for (let i = 0; i < chartRows.length; i = i + INSERT_CHUNK) {
const chunk = chartRows.slice(i, i + INSERT_CHUNK);
await pg
.insertInto("chart")
.values(chunk)
.onConflict((oc) =>
oc.column("id").doUpdateSet({
level: sql`excluded.level`,
level_num: sql`excluded.level_num`,
is_primary: sql`excluded.is_primary`,
difficulty: sql`excluded.difficulty`,
data: sql`excluded.data`,
}),
)
.execute();
}
await chunkedDeletePg(pg, "chart_version", "chart_id", chartSids);
await batchIgnorePg(pg, "chart_version", versionRows);
}
export type ImportSeedsSubsetOptions = {
gameGroups: GameGroup[];
/** When true (default), load chart rows whose `songID` is in the loaded song set. */
includeCharts?: boolean;
maxSongsPerGame: number;
};
/**
* Loads a bounded slice of real seed JSON (songs + optional charts) for tests or tooling.
*/
export async function importSeedsSubset(
pg: Kysely<Database>,
seedsDir: string,
options: ImportSeedsSubsetOptions,
): Promise<void> {
const { maxSongsPerGame, gameGroups, includeCharts = true } = options;
const loadedSongIds = new Set<string>();
for (const gg of gameGroups) {
const filename = `songs-${gg}.json`;
const filepath = path.join(seedsDir, filename);
if (!fs.existsSync(filepath)) {
throw new Error(`seed file not found: ${filepath}`);
}
const all = readJsonSeed<SeedSong>(seedsDir, filename);
const songs = all.slice(0, maxSongsPerGame);
await upsertSongsForGameGroup(pg, gg, songs);
for (const s of songs) {
loadedSongIds.add(s.id);
}
}
if (!includeCharts) {
return;
}
const chartFiles = fs
.readdirSync(seedsDir)
.filter((f) => f.startsWith("charts-") && f.endsWith(".json"));
for (const gg of gameGroups) {
const filesForGame = chartFiles.filter((f) => f.startsWith(`charts-${gg}`));
for (const file of filesForGame) {
const pgGame = file.replace(/^charts-/u, "").replace(/\.json$/u, "") as PgGame;
const charts = readJsonSeed<SeedChart>(seedsDir, file).filter((c) =>
loadedSongIds.has(c.songID),
);
await upsertChartsForPgGame(pg, pgGame, charts);
}
}
}
// ── Game helpers ───────────────────────────────────────────────────────────
const SINGLE_PT_GAMES = new Set([
@@ -113,43 +342,7 @@ export function buildChartIdMap(seedsDir: string): Map<string, string> {
// ── Core import logic ──────────────────────────────────────────────────────
export async function importSeeds(pg: Kysely<Database>, seedsDir: string): Promise<void> {
const INSERT_CHUNK = 500;
async function batchIgnore<T extends keyof Database>(
table: T,
rows: ReadonlyArray<Insertable<Database[T]>>,
): Promise<void> {
if (rows.length === 0) {
return;
}
for (let i = 0; i < rows.length; i = i + INSERT_CHUNK) {
const chunk = rows.slice(i, i + INSERT_CHUNK);
await pg
.insertInto(table)
.values(chunk as never)
.onConflict((oc) => oc.doNothing())
.execute();
}
}
// Deletes rows matching a large list of IDs by chunking the IN clause.
async function chunkedDelete(
table: keyof Database,
column: string,
ids: ReadonlyArray<string>,
): Promise<void> {
for (let i = 0; i < ids.length; i = i + INSERT_CHUNK) {
const chunk = ids.slice(i, i + INSERT_CHUNK);
await (pg.deleteFrom(table as never) as any).where(column, "in", chunk).execute();
}
}
function readCollection<T>(filename: string): Array<T> {
return JSON.parse(fs.readFileSync(path.join(seedsDir, filename), "utf-8")) as Array<T>;
}
const readCollection = <T>(filename: string) => readJsonSeed<T>(seedsDir, filename);
const files = new Set<string>(fs.readdirSync(seedsDir));
const songFiles = [...files].filter((f) => f.startsWith("songs-") && f.endsWith(".json"));
@@ -166,63 +359,7 @@ export async function importSeeds(pg: Kysely<Database>, seedsDir: string): Promi
const gameGroup = file.replace(/^songs-/u, "").replace(/\.json$/u, "") as GameGroup;
const songs = readCollection<SeedSong>(file);
const songRows: Array<NewSong> = [];
const searchTermRows: Array<NewSongSearchTerm> = [];
const altTitleRows: Array<NewSongAltTitle> = [];
for (const s of songs) {
if (!s.id) {
throw new Error(
`Song ${gameGroup}:${s.legacySongID} is missing an id. Run 1-migrate-to-pg-style.ts first.`,
);
}
songRows.push({
id: s.id,
legacy_id:
s.legacySongID ??
(() => {
throw new Error(`Song ${gameGroup}:${s.id} is missing legacySongID.`);
})(),
game_group: gameGroup,
title: s.title,
artist: s.artist,
data: JSON.stringify(s.data),
});
for (const term of s.searchTerms) {
searchTermRows.push({ song_id: s.id, search_term: term });
}
for (const alt of s.altTitles) {
altTitleRows.push({ song_id: s.id, alt_title: alt });
}
}
const songIds = songs.map((s) => s.id);
for (let i = 0; i < songRows.length; i = i + INSERT_CHUNK) {
const chunk = songRows.slice(i, i + INSERT_CHUNK);
await pg
.insertInto("song")
.values(chunk)
.onConflict((oc) =>
oc.column("id").doUpdateSet({
title: sql`excluded.title`,
artist: sql`excluded.artist`,
data: sql`excluded.data`,
}),
)
.execute();
}
// Delete + reinsert child rows so removals from seeds are reflected.
await chunkedDelete("song_search_term", "song_id", songIds);
await chunkedDelete("song_alt_title", "song_id", songIds);
await batchIgnore("song_search_term", searchTermRows);
await batchIgnore("song_alt_title", altTitleRows);
await upsertSongsForGameGroup(pg, gameGroup, songs);
total = total + songs.length;
console.log(` ${gameGroup}: ${songs.length} songs`);
@@ -242,63 +379,7 @@ export async function importSeeds(pg: Kysely<Database>, seedsDir: string): Promi
const pgGame = file.replace(/^charts-/u, "").replace(/\.json$/u, "") as PgGame;
const charts = readCollection<SeedChart>(file);
const chartRows: Array<NewChart> = [];
const versionRows: Array<NewChartVersion> = [];
for (const c of charts) {
if (!c.id) {
throw new Error(
`Chart ${c.legacyChartID ?? "(unknown)"}` +
` (${pgGame}) is missing an id. Run 1-migrate-to-pg-style.ts first.`,
);
}
if (!c.legacyChartID) {
throw new Error(
`Chart ${c.id} (${pgGame}) is missing legacyChartID. Run 1-migrate-to-pg-style.ts first.`,
);
}
chartRows.push({
id: c.id,
legacy_id: c.legacyChartID,
game: pgGame,
song_id: c.songID,
level: c.level,
level_num: c.levelNum,
is_primary: c.isPrimary,
difficulty: c.difficulty,
data: JSON.stringify(c.data),
});
for (const version of c.versions) {
versionRows.push({ chart_id: c.id, version: version as string });
}
}
const chartSids = charts.map((c) => c.id);
for (let i = 0; i < chartRows.length; i = i + INSERT_CHUNK) {
const chunk = chartRows.slice(i, i + INSERT_CHUNK);
await pg
.insertInto("chart")
.values(chunk)
.onConflict((oc) =>
oc.column("id").doUpdateSet({
level: sql`excluded.level`,
level_num: sql`excluded.level_num`,
is_primary: sql`excluded.is_primary`,
difficulty: sql`excluded.difficulty`,
data: sql`excluded.data`,
}),
)
.execute();
}
await chunkedDelete("chart_version", "chart_id", chartSids);
await batchIgnore("chart_version", versionRows);
await upsertChartsForPgGame(pg, pgGame, charts);
total = total + charts.length;
console.log(` ${pgGame}: ${charts.length} charts`);
@@ -345,13 +426,13 @@ export async function importSeeds(pg: Kysely<Database>, seedsDir: string): Promi
const folderIds = folders.map((f) => f.id);
await chunkedDelete("folder_search_term", "id", folderIds);
await chunkedDeletePg(pg, "folder_search_term", "id", folderIds);
const termRows: Array<NewFolderSearchTerm> = folders.flatMap((f) =>
f.searchTerms.map((term) => ({ id: f.id, search_term: term })),
);
await batchIgnore("folder_search_term", termRows);
await batchIgnorePg(pg, "folder_search_term", termRows);
console.log(` ${folders.length} folders, ${termRows.length} search terms\n`);
}
@@ -393,13 +474,13 @@ export async function importSeeds(pg: Kysely<Database>, seedsDir: string): Promi
const tableIds = tables.map((t) => t.id);
await chunkedDelete("table_folder", "table_id", tableIds);
await chunkedDeletePg(pg, "table_folder", "table_id", tableIds);
const tfRows: Array<NewTableFolder> = tables.flatMap((t) =>
t.folders.map((folderId) => ({ table_id: t.id, folder_id: folderId })),
);
await batchIgnore("table_folder", tfRows);
await batchIgnorePg(pg, "table_folder", tfRows);
console.log(` ${tables.length} tables, ${tfRows.length} table-folder rows\n`);
}
@@ -416,7 +497,7 @@ export async function importSeeds(pg: Kysely<Database>, seedsDir: string): Promi
value: c.value as string,
}));
await batchIgnore("bms_course_lookup", courseRows);
await batchIgnorePg(pg, "bms_course_lookup", courseRows);
console.log(` ${courses.length} BMS courses\n`);
}
@@ -434,7 +515,7 @@ export async function importSeeds(pg: Kysely<Database>, seedsDir: string): Promi
}));
// Goals are never updated once created — only new ones are inserted.
await batchIgnore("goal", goalRows);
await batchIgnorePg(pg, "goal", goalRows);
console.log(` ${goals.length} goals\n`);
}
@@ -499,7 +580,7 @@ export async function importSeeds(pg: Kysely<Database>, seedsDir: string): Promi
const qlIds = questlines.map((ql) => ql.questlineID);
await chunkedDelete("questline_quest", "questline_id", qlIds);
await chunkedDeletePg(pg, "questline_quest", "questline_id", qlIds);
let order = 0;
const qlqRows: Array<NewQuestlineQuest> = questlines.flatMap((ql) => {
@@ -512,7 +593,7 @@ export async function importSeeds(pg: Kysely<Database>, seedsDir: string): Promi
}));
});
await batchIgnore("questline_quest", qlqRows);
await batchIgnorePg(pg, "questline_quest", qlqRows);
console.log(` ${questlines.length} questlines, ${qlqRows.length} questline-quest rows\n`);
}
}
@@ -0,0 +1,19 @@
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import path from "path";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** Default `db/seeds` (collections JSON) relative to the server package — same as `load-seeds-pg.ts`. */
export const DEFAULT_SEEDS_DIR = path.resolve(__dirname, "../../../../db/seeds");
export function resolveSeedsDir(): string {
return process.env.SEEDS_DIR ?? DEFAULT_SEEDS_DIR;
}
/** True when the repo’s seed JSON is present (e.g. `songs-iidx.json`). */
export function seedsJsonAvailable(): boolean {
const dir = resolveSeedsDir();
return fs.existsSync(path.join(dir, "songs-iidx.json"));
}
+13
View File
@@ -0,0 +1,13 @@
import { bench, describe } from "vitest";
import { EscapeForILIKE } from "#utils/misc";
/** Representative user search string (wildcards + backslashes) for ILIKE escaping. */
const sampleQuery =
"artist_%track% " + "word ".repeat(32) + String.raw` \% literal \_ `;
describe("EscapeForILIKE (example)", () => {
bench("typical search string", () => {
EscapeForILIKE(sampleQuery);
});
});
+83 -64
View File
@@ -1,16 +1,21 @@
import type { FilterQuery } from "mongodb";
import type {
ChartDocument,
Difficulties,
GameGroup,
GPTString,
integer,
Playtype,
Playtypes,
Versions,
} from "tachi-common";
import type { Game } from "tachi-db";
import MONGODB_KILL from "#services/mongo/db";
import DB from "#services/pg/db";
import { sql } from "kysely";
import {
type ChartDocument,
type ChartDocumentData,
type Difficulties,
type GameGroup,
GamePTToV3,
type GPTString,
type integer,
type Playtype,
type Playtypes,
V3ToGamePT,
type Versions,
} from "tachi-common";
export function FindChartWithChartID(game: GameGroup, chartID: string) {
return MONGODB_KILL.anyCharts[game].findOne({ chartID });
@@ -230,8 +235,9 @@ export function FindChartOnSHA256Playtype(game: GameGroup, hash: string, playtyp
/**
* Returns the N most popular charts for this game + playtype.
* Popularity is determined by how many scores match in the score
* collection.
* Popularity is determined by how many rows exist in Postgres `score` for each chart.
*
* @param _scoreCollection — ignored; kept for API compatibility with the old Mongo implementation.
*/
export async function FindChartsOnPopularity(
game: GameGroup,
@@ -239,64 +245,77 @@ export async function FindChartsOnPopularity(
songIDs?: Array<integer>,
skip = 0,
limit = 100,
scoreCollection: "personal-bests" | "scores" = "personal-bests",
_scoreCollection: "personal-bests" | "scores" = "personal-bests",
): Promise<Array<{ __playcount: integer } & ChartDocument>> {
const matchQuery: FilterQuery<ChartDocument> = {
playtype,
};
const v3Game = GamePTToV3(game, playtype);
if (songIDs) {
matchQuery.songID = { $in: songIDs };
let q = DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.leftJoin("score", "score.chart_id", "chart.id")
.where("chart.game", "=", v3Game as Game);
if (songIDs && songIDs.length > 0) {
q = q.where("song.legacy_id", "in", songIDs);
}
// MongoDB is a hard beast to wield.
// This code might look very inefficient, but originally this *was*
// a single aggregate pipeline.
//
// We've split it up into multiple queries as this is an order of
// magnitude faster.
// Not entirely sure why, but $lookup is incredibly inefficient,
// and you should just avoid it.
const charts = (await MONGODB_KILL.anyCharts[game].find(matchQuery)) as unknown as Array<
{
__playcount: integer;
} & ChartDocument
>;
const rows = await q
.select([
"chart.id",
"chart.legacy_id",
"chart.game",
"chart.song_id",
"chart.level",
"chart.level_num",
"chart.is_primary",
"chart.difficulty",
"chart.data",
"song.legacy_id as song_legacy_id",
sql<number>`count(score.id)::int`.as("playcount"),
])
.groupBy(["chart.id", "song.legacy_id"])
.orderBy(sql`count(score.id)`, "desc")
.offset(skip)
.limit(limit)
.execute();
const scoreCounts: Array<{ _id: string; count: integer }> = await MONGODB_KILL[
scoreCollection
].aggregate([
{
$match: { chartID: { $in: charts.map((e) => e.chartID) } },
},
{
$group: {
_id: "$chartID",
count: { $sum: 1 },
},
},
{
$sort: {
count: -1,
},
},
{
$skip: skip,
},
{
$limit: limit,
},
]);
const scoreCountMap = new Map<string, integer>();
for (const sc of scoreCounts) {
scoreCountMap.set(sc._id, sc.count);
if (rows.length === 0) {
return [];
}
for (const chart of charts) {
chart.__playcount = scoreCountMap.get(chart.chartID) ?? 0;
const chartPgIds = rows.map((r) => r.id);
const versionRows = await DB.selectFrom("chart_version")
.select(["chart_id", "version"])
.where("chart_id", "in", chartPgIds)
.execute();
const versionsByChartId = new Map<string, string[]>();
for (const v of versionRows) {
let list = versionsByChartId.get(v.chart_id);
if (!list) {
list = [];
versionsByChartId.set(v.chart_id, list);
}
list.push(v.version);
}
return charts.sort((a, b) => b.__playcount - a.__playcount).slice(skip, skip + limit);
return rows.map((row) => {
const { playtype: chartPlaytype } = V3ToGamePT(row.game);
return {
chartID: row.legacy_id,
songID: row.song_legacy_id,
level: row.level,
levelNum: row.level_num,
isPrimary: row.is_primary,
difficulty: row.difficulty as Difficulties[GPTString],
playtype: chartPlaytype,
data: row.data as ChartDocumentData[GPTString],
versions: (versionsByChartId.get(row.id) ?? []) as Versions[GPTString][],
__playcount: row.playcount,
} as { __playcount: integer } & ChartDocument;
});
}
+6 -5
View File
@@ -19,6 +19,7 @@ import {
type UserDocument,
type UserGameStats,
type UserSettingsDocument,
V3ToGamePT,
} from "tachi-common";
import { type Database } from "tachi-db";
@@ -299,12 +300,12 @@ export async function IsUserBanned(userID: integer) {
* Return all the GPTs this userID has played.
*/
export async function GetUserPlayedGPTs(userID: integer) {
const gpts = (await MONGODB_KILL["game-stats"].find(
{ userID },
{ projection: { game: 1, playtype: 1 } },
)) as Array<Pick<UserGameStats, "game" | "playtype">>;
const rows = await DB.selectFrom("game_profile")
.select("game")
.where("user_id", "=", userID)
.execute();
return gpts;
return rows.map((r) => V3ToGamePT(r.game));
}
export async function GetAllUserRivals(userID: integer) {
+4 -1
View File
@@ -20,6 +20,9 @@ export default defineConfig({
},
test: {
// `vitest bench` uses this same config: globalSetup + setupFiles + per-worker POSTGRES_URL.
// Use for API / DB performance work as well as microbenches (*.bench.ts).
//
// Static env vars. POSTGRES_URL is set dynamically per-worker in vitest.setup.ts
// so each worker gets its own isolated database.
env: {
@@ -45,7 +48,7 @@ export default defineConfig({
coverage: {
provider: "v8",
include: ["src/**/*.ts"],
exclude: ["src/**/*.test.ts", "src/**/*.oldtest.ts", "src/test-utils/**"],
exclude: ["src/**/*.test.ts", "src/**/*.bench.ts", "src/**/*.oldtest.ts", "src/test-utils/**"],
},
},
});
+15 -1
View File
@@ -93,7 +93,21 @@ beforeAll(async () => {
await createWorkerDatabase();
});
beforeEach(async () => {
beforeEach(async (ctx) => {
// Benchmark tasks load real seed data in beforeAll; truncating here would wipe it
// before every bench() and between iterations.
const task = ctx.task as { file?: { filepath?: string }; meta?: { benchmark?: boolean } };
if (task.meta?.benchmark === true) {
return;
}
const fp = task.file?.filepath;
if (typeof fp === "string" && fp.endsWith(".bench.ts")) {
return;
}
await resetDatabase();
// Login-heavy router tests share the in-memory login rate limiter; reset each
// test so AggressiveRateLimit (15 / 10 min) does not 429 and omit Set-Cookie.
+2 -1
View File
@@ -10,7 +10,8 @@
"lint": "eslint .",
"lint-fix": "eslint . --fix",
"typecheck": "tsgo --noEmit",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"bench": "node -e \"process.exit(0)\""
},
"author": "zk",
"license": "MIT",