mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-13 01:38:29 +03:00
rebel shell
This commit is contained in:
@@ -32,7 +32,7 @@ export default defineConfig({
|
||||
DISCORD_SERVER_ID: "test-server-id",
|
||||
},
|
||||
|
||||
// Parallel test execution — each worker gets its own isolated Postgres database.
|
||||
// Parallel test execution - each worker gets its own isolated Postgres database.
|
||||
fileParallelism: true,
|
||||
globalSetup: "./vitest.globalSetup.ts",
|
||||
setupFiles: "./vitest.setup.ts",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Global vitest setup — runs ONCE before any workers start.
|
||||
* Global vitest setup - runs ONCE before any workers start.
|
||||
*
|
||||
* Creates a fully-migrated template database. Workers clone from it
|
||||
* instead of running migrations themselves, which is much faster.
|
||||
|
||||
@@ -20,7 +20,7 @@ const POSTGRES_HOST = "tachi-postgres";
|
||||
const POSTGRES_USER = "tachi";
|
||||
const POSTGRES_PASS = "tachi";
|
||||
|
||||
// Set POSTGRES_URL before any app code is imported — config.ts reads it at load time.
|
||||
// Set POSTGRES_URL before any app code is imported - config.ts reads it at load time.
|
||||
process.env.POSTGRES_URL = `postgresql://${POSTGRES_USER}:${POSTGRES_PASS}@${POSTGRES_HOST}/${WORKER_DB_NAME}`;
|
||||
|
||||
// Mock global fetch before any app code is imported.
|
||||
@@ -45,7 +45,7 @@ globalThis.fetch = (url: string | Request | URL): Promise<Response> => {
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
// Blow up loudly if any unexpected fetch slips through — this keeps tests hermetic.
|
||||
// Blow up loudly if any unexpected fetch slips through - this keeps tests hermetic.
|
||||
throw new Error(`Unexpected fetch() in test: ${url.toString()}`);
|
||||
};
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ type ActionsResponse = {
|
||||
function durationMs(start: string, end: string): string {
|
||||
const ms = Date.parse(end) - Date.parse(start);
|
||||
if (Number.isNaN(ms)) {
|
||||
return "—";
|
||||
return "-";
|
||||
}
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
@@ -196,11 +196,11 @@ export default function AdminActionsPage() {
|
||||
{action.username}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-body-secondary">—</span>
|
||||
<span className="text-body-secondary">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="font-monospace small">
|
||||
{action.ip ?? "—"}
|
||||
{action.ip ?? "-"}
|
||||
</td>
|
||||
<td className="align-middle">
|
||||
<Button
|
||||
|
||||
@@ -31,11 +31,11 @@ type CronResponse = {
|
||||
|
||||
function durationMs(startedAt: string, completedAt: string | null): string {
|
||||
if (!completedAt) {
|
||||
return "—";
|
||||
return "-";
|
||||
}
|
||||
const ms = Date.parse(completedAt) - Date.parse(startedAt);
|
||||
if (Number.isNaN(ms)) {
|
||||
return "—";
|
||||
return "-";
|
||||
}
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
@@ -175,7 +175,7 @@ export default function AdminCronJobsPage() {
|
||||
</details>
|
||||
) : (
|
||||
<span className="text-body-secondary">
|
||||
—
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function AdminOperationsPage() {
|
||||
}}
|
||||
value={announcementGame === "" ? "" : announcementGame}
|
||||
>
|
||||
<option value="">— Site-wide —</option>
|
||||
<option value="">- Site-wide -</option>
|
||||
{TachiConfig.GAME_GROUPS.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
@@ -62,7 +62,7 @@ export default function AdminOperationsPage() {
|
||||
onChange={(e) => setAnnouncementPlaytype(e.target.value)}
|
||||
value={announcementPlaytype}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="">-</option>
|
||||
{announcementGameConfig.playtypes.map((pt) => (
|
||||
<option key={pt} value={pt}>
|
||||
{FormatGame(
|
||||
|
||||
@@ -187,7 +187,7 @@ export default function UserOrphansPage({ reqUser }: { reqUser: UserDocument })
|
||||
|
||||
<Modal onHide={() => setDetailOpen(false)} show={detailOpen} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Orphan details — {detailTitle}</Modal.Title>
|
||||
<Modal.Title>Orphan details - {detailTitle}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{detailLoading ? (
|
||||
@@ -221,7 +221,7 @@ export default function UserOrphansPage({ reqUser }: { reqUser: UserDocument })
|
||||
]}
|
||||
rowFunction={(o) => (
|
||||
<tr key={o.orphanID}>
|
||||
<td>{o.summary ?? "—"}</td>
|
||||
<td>{o.summary ?? "-"}</td>
|
||||
<td>
|
||||
<code className="small">{o.importType}</code>
|
||||
</td>
|
||||
@@ -230,7 +230,7 @@ export default function UserOrphansPage({ reqUser }: { reqUser: UserDocument })
|
||||
o.gameGroup}
|
||||
</td>
|
||||
<td>{new Date(o.timeInserted).toLocaleString()}</td>
|
||||
<td className="small text-muted">{o.message ?? "—"}</td>
|
||||
<td className="small text-muted">{o.message ?? "-"}</td>
|
||||
<td>
|
||||
<div className="d-flex flex-wrap gap-1">
|
||||
<Button
|
||||
|
||||
+58
-2
@@ -6,18 +6,19 @@ import DebugContent from "#components/util/DebugContent";
|
||||
import Divider from "#components/util/Divider";
|
||||
import EditableText from "#components/util/EditableText";
|
||||
import Icon from "#components/util/Icon";
|
||||
import LinkButton from "#components/util/LinkButton";
|
||||
import Loading from "#components/util/Loading";
|
||||
import useApiQuery from "#components/util/query/useApiQuery";
|
||||
import { UserContext } from "#context/UserContext";
|
||||
import { UserSettingsContext } from "#context/UserSettingsContext";
|
||||
import { type SessionReturns } from "#types/api-returns";
|
||||
import { type SessionAdjacentReturns, type SessionReturns } from "#types/api-returns";
|
||||
import { type UGPT } from "#types/react";
|
||||
import { APIFetchV1 } from "#util/api";
|
||||
import { CreateChartMap, CreateScoreIDMap, CreateSongMap } from "#util/data";
|
||||
import React, { useContext, useMemo, useState } from "react";
|
||||
import { Badge, Button, Col, Row } from "react-bootstrap";
|
||||
import { Redirect, useParams } from "react-router-dom";
|
||||
import { GameToGameGroup, GetGameGroupConfig } from "tachi-common";
|
||||
import { GameToGameGroup, GetGameGroupConfig, type SessionDocument } from "tachi-common";
|
||||
|
||||
export default function SpecificSessionPage({ reqUser, game }: UGPT) {
|
||||
const { sessionID } = useParams<{ sessionID: string }>();
|
||||
@@ -127,8 +128,20 @@ function SessionPage({ data, game }: { data: SessionReturns } & UGPT) {
|
||||
);
|
||||
};
|
||||
|
||||
const { data: adjacentData } = useApiQuery<SessionAdjacentReturns>(
|
||||
`/sessions/${session.sessionID}/adjacent`,
|
||||
);
|
||||
|
||||
return (
|
||||
<Row className="justify-content-center">
|
||||
<Col xs={12}>
|
||||
<SessionNavRow
|
||||
adjacent={adjacentData ?? null}
|
||||
currentName={session.name}
|
||||
game={game}
|
||||
username={user.username}
|
||||
/>
|
||||
</Col>
|
||||
<Col className="text-center" xs={12}>
|
||||
<div className="d-flex flex-column gap-2 flex-wrap justify-content-center align-items-center">
|
||||
<EditableText
|
||||
@@ -259,3 +272,46 @@ function SessionPage({ data, game }: { data: SessionReturns } & UGPT) {
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionNavRow({
|
||||
adjacent,
|
||||
currentName,
|
||||
game,
|
||||
username,
|
||||
}: {
|
||||
adjacent: SessionAdjacentReturns | null;
|
||||
currentName: string;
|
||||
game: string;
|
||||
username: string;
|
||||
}) {
|
||||
const sessionUrl = (s: SessionDocument) =>
|
||||
`/u/${username}/games/${s.game}/sessions/${s.sessionID}`;
|
||||
|
||||
return (
|
||||
<div className="d-flex align-items-center justify-content-between mb-4">
|
||||
<div style={{ minWidth: "140px" }}>
|
||||
{adjacent?.prev ? (
|
||||
<LinkButton to={sessionUrl(adjacent.prev)} variant="outline-secondary">
|
||||
« Prev
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Button disabled variant="outline-secondary">
|
||||
« Prev
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted text-truncate px-2">{currentName}</span>
|
||||
<div style={{ minWidth: "140px" }} className="text-end">
|
||||
{adjacent?.next ? (
|
||||
<LinkButton to={sessionUrl(adjacent.next)} variant="outline-secondary">
|
||||
Next »
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Button disabled variant="outline-secondary">
|
||||
Next »
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ import {
|
||||
type UserDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
function isActivityInteractiveTarget(el: HTMLElement) {
|
||||
return Boolean(el.closest("a, button, input, textarea, select, [role='button']"));
|
||||
}
|
||||
|
||||
// Records activity for a group of users on a GPT. Also used for single users.
|
||||
export default function Activity({
|
||||
url,
|
||||
@@ -291,7 +295,16 @@ function ScoresActivity({
|
||||
<div className="timeline-item timeline-hover my-4">
|
||||
<div className="timeline-badge bg-warning"></div>
|
||||
<div className="timeline-content flex-nowrap">
|
||||
<div className="timeline-content-inner" onClick={() => setShow(!show)}>
|
||||
<div
|
||||
className="timeline-content-inner activity-entry-toggle"
|
||||
onClick={(e) => {
|
||||
if (isActivityInteractiveTarget(e.target as HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShow(!show);
|
||||
}}
|
||||
>
|
||||
<div className="timeline-content-title">
|
||||
<span className="me-2">
|
||||
<ProfilePicture size="sm" toGPT={{ game }} user={user} />
|
||||
@@ -323,10 +336,10 @@ function ScoresActivity({
|
||||
</div>
|
||||
|
||||
{show && (
|
||||
<>
|
||||
<div className="activity-expand-body">
|
||||
<Divider />
|
||||
<ScoreTable dataset={dataset} game={game} noTopDisplayStr />
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -367,7 +380,16 @@ function GoalActivity({
|
||||
<div className="timeline-item timeline-hover my-4">
|
||||
<div className="timeline-badge bg-warning"></div>
|
||||
<div className="timeline-content">
|
||||
<div className="timeline-content-inner" onClick={() => setShow(!show)}>
|
||||
<div
|
||||
className="timeline-content-inner activity-entry-toggle"
|
||||
onClick={(e) => {
|
||||
if (isActivityInteractiveTarget(e.target as HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShow(!show);
|
||||
}}
|
||||
>
|
||||
<div className="timeline-content-title">
|
||||
<span className="me-2">
|
||||
<ProfilePicture size="sm" toGPT={{ game }} user={user} />
|
||||
@@ -399,7 +421,7 @@ function GoalActivity({
|
||||
</div>
|
||||
|
||||
{show && (
|
||||
<>
|
||||
<div className="activity-expand-body">
|
||||
<Divider />
|
||||
<div className="ps-4">
|
||||
{data.goals.map((e) => (
|
||||
@@ -410,7 +432,7 @@ function GoalActivity({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -485,7 +507,16 @@ function SessionActivity({
|
||||
<div className="timeline-item timeline-hover">
|
||||
<div className={`timeline-badge bg-${data.highlight ? "warning" : "secondary"}`}></div>
|
||||
<div className="timeline-content">
|
||||
<div className="timeline-content-inner" onClick={() => setShow(!show)}>
|
||||
<div
|
||||
className="timeline-content-inner activity-entry-toggle"
|
||||
onClick={(e) => {
|
||||
if (isActivityInteractiveTarget(e.target as HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShow(!show);
|
||||
}}
|
||||
>
|
||||
<div className="timeline-content-title">
|
||||
<span className="me-2">
|
||||
<ProfilePicture size="sm" toGPT={{ game }} user={user} />
|
||||
@@ -529,7 +560,11 @@ function SessionActivity({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{show && <SessionShower sessionID={data.sessionID} />}
|
||||
{show && (
|
||||
<div className="activity-expand-body">
|
||||
<SessionShower sessionID={data.sessionID} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -131,7 +131,7 @@ export default function ImportInfo({
|
||||
<br />
|
||||
<strong>SongOrChartNotFound</strong> means the score was still{" "}
|
||||
<strong>saved as an orphan</strong> for nightly matching (around 00:01
|
||||
UTC) or manual reprocess — see{" "}
|
||||
UTC) or manual reprocess - see{" "}
|
||||
{user ? (
|
||||
<Link to={`/u/${user.username}/orphans`}>Orphan scores</Link>
|
||||
) : (
|
||||
|
||||
@@ -22,10 +22,10 @@ try {
|
||||
// Do NOT use document.open() / document.write() / document.close() here.
|
||||
// document.open() fires unload/beforeunload/pagehide events, which Vite's HMR
|
||||
// client intercepts to close its WebSocket. The HMR reconnection logic then
|
||||
// triggers a full page reload, re-running this module, failing again — an
|
||||
// triggers a full page reload, re-running this module, failing again - an
|
||||
// infinite refresh loop.
|
||||
//
|
||||
// Do NOT throw either — an uncaught module-evaluation error is another signal
|
||||
// Do NOT throw either - an uncaught module-evaluation error is another signal
|
||||
// that Vite HMR uses to trigger a reload.
|
||||
//
|
||||
// Instead, use direct DOM mutation (no unload events) and console.error.
|
||||
|
||||
@@ -120,7 +120,6 @@ const IIDX_PROFILE_RATING_VALUE_CELL_STYLE: NonNullable<
|
||||
GPTClientImplementation<GamesForGroup["iidx"]>["profileRatingAlgRowStyle"]
|
||||
> = {
|
||||
ktLampRating: { backgroundColor: ChangeOpacity(COLOUR_SET.purple, 0.12) },
|
||||
ktLampRatingNC: { backgroundColor: ChangeOpacity(COLOUR_SET.blue, 0.14) },
|
||||
ktLampRatingHC: { backgroundColor: ChangeOpacity(COLOUR_SET.orange, 0.14) },
|
||||
ktLampRatingEXHC: { backgroundColor: ChangeOpacity(COLOUR_SET.gold, 0.12) },
|
||||
BPI: { backgroundColor: ChangeOpacity(COLOUR_SET.paleBlue, 0.12) },
|
||||
|
||||
@@ -93,3 +93,23 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Activity feed (Activity.tsx): row hover covers full `.timeline-item`, but clickable
|
||||
// region was only `.timeline-content-inner` inset inside padded `.timeline-content`.
|
||||
// Move padding onto the toggle row so clicks match the highlight.
|
||||
.timeline.activity-timeline {
|
||||
.timeline-item .timeline-content:has(> .activity-entry-toggle:first-child) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.timeline-item .activity-entry-toggle {
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.timeline-item .activity-expand-body {
|
||||
padding: 0 1rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,11 @@ export interface SessionReturns<GPT extends V3Game = V3Game> {
|
||||
user: UserDocument;
|
||||
}
|
||||
|
||||
export interface SessionAdjacentReturns {
|
||||
prev: SessionDocument | null;
|
||||
next: SessionDocument | null;
|
||||
}
|
||||
|
||||
export interface UGPTChartPBComposition<GPT extends V3Game = V3Game> {
|
||||
scores: ScoreDocument<GPT>[];
|
||||
chart: ChartDocument<GPT>;
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
files: "src/**/*.test.ts"
|
||||
test-env: NODE_ENV=test
|
||||
timeout: 120
|
||||
ts: true
|
||||
|
||||
# all this is broken now for some reason. who knows.
|
||||
check-coverage: false
|
||||
|
||||
# branches: 0 # 80
|
||||
# functions: 0 # 80
|
||||
# lines: 0 # 80
|
||||
# statements 0: # 80
|
||||
reporter: terse
|
||||
@@ -172,10 +172,6 @@ export const GAME_IIDX_SP_CONF = {
|
||||
description:
|
||||
"A rating system that values your clear lamps on charts. Tierlist information is taken into account.",
|
||||
},
|
||||
ktLampRatingNC: {
|
||||
description:
|
||||
"Your normal clear performance. Tierlist information is taken into account.",
|
||||
},
|
||||
ktLampRatingHC: {
|
||||
description: "Your hard clear performance. Tierlist information is taken into account.",
|
||||
},
|
||||
@@ -195,30 +191,24 @@ export const GAME_IIDX_SP_CONF = {
|
||||
associatedScoreAlgs: ["ktLampRating"],
|
||||
displayOrder: 0,
|
||||
},
|
||||
ktLampRatingNC: {
|
||||
description: `An average of your best 20 ktLampRatingNCs.`,
|
||||
associatedScoreAlgs: ["ktLampRatingNC"],
|
||||
displayOrder: 1,
|
||||
},
|
||||
ktLampRatingHC: {
|
||||
description: `An average of your best 20 ktLampRatingHCs.`,
|
||||
associatedScoreAlgs: ["ktLampRatingHC"],
|
||||
displayOrder: 2,
|
||||
displayOrder: 1,
|
||||
},
|
||||
ktLampRatingEXHC: {
|
||||
description: `An average of your best 20 ktLampRatingEXHCs.`,
|
||||
associatedScoreAlgs: ["ktLampRatingEXHC"],
|
||||
displayOrder: 3,
|
||||
displayOrder: 2,
|
||||
},
|
||||
BPI: {
|
||||
description: `An average of your best 20 BPIs.`,
|
||||
associatedScoreAlgs: ["BPI"],
|
||||
displayOrder: 4,
|
||||
displayOrder: 3,
|
||||
},
|
||||
},
|
||||
sessionRatingAlgs: {
|
||||
ktLampRating: { description: `An average of the best 10 ktLampRatings this session.` },
|
||||
ktLampRatingNC: { description: `An average of the best 10 ktLampRatingNCs this session.` },
|
||||
ktLampRatingHC: { description: `An average of the best 10 ktLampRatingHCs this session.` },
|
||||
ktLampRatingEXHC: {
|
||||
description: `An average of the best 10 ktLampRatingEXHCs this session.`,
|
||||
|
||||
@@ -351,7 +351,7 @@ function encodeBmsTableKey(game: string, key: string): string {
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No BMS_TABLES row for game=${JSON.stringify(game)} prefix=${JSON.stringify(key)} — add a BMSTableInfo entry or use an ascii-only prefix.`,
|
||||
`No BMS_TABLES row for game=${JSON.stringify(game)} prefix=${JSON.stringify(key)} - add a BMSTableInfo entry or use an ascii-only prefix.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
NODE_ENV: "test",
|
||||
},
|
||||
exclude: [
|
||||
// Compiled output — not test sources.
|
||||
// Compiled output - not test sources.
|
||||
"build/**",
|
||||
],
|
||||
coverage: {
|
||||
|
||||
@@ -119,7 +119,7 @@ function printHuman(report: CoverageReport): void {
|
||||
|
||||
for (const p of report.packages) {
|
||||
if (!p.present || !p.metrics) {
|
||||
console.log(`${p.id.padEnd(10)} (no data — run: just test-typescript)`);
|
||||
console.log(`${p.id.padEnd(10)} (no data - run: just test-typescript)`);
|
||||
continue;
|
||||
}
|
||||
console.log(row(p.id, p.metrics));
|
||||
|
||||
@@ -254,7 +254,7 @@ migrateCmd
|
||||
for (const info of infos) {
|
||||
const status =
|
||||
info.success === null ? "Pending" : info.success ? "Applied" : "Failed";
|
||||
const appliedAt = info.appliedAt ? formatDate(info.appliedAt) : "—";
|
||||
const appliedAt = info.appliedAt ? formatDate(info.appliedAt) : "-";
|
||||
const desc =
|
||||
info.description.length > descWidth - 2
|
||||
? `${info.description.slice(0, descWidth - 5)}...`
|
||||
|
||||
@@ -142,7 +142,7 @@ async function withAdvisoryLock<T>(client: Client, fn: () => Promise<T>): Promis
|
||||
|
||||
/**
|
||||
* Apply all pending migrations from migrationsDir against the given connection.
|
||||
* Safe to call on every startup — already-applied migrations are skipped.
|
||||
* Safe to call on every startup - already-applied migrations are skipped.
|
||||
* Throws if a previously-applied migration's file has been modified (checksum mismatch).
|
||||
*/
|
||||
export async function applyMigrations(
|
||||
@@ -180,7 +180,7 @@ export async function applyMigrations(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Previously attempted and failed — clear the record and retry.
|
||||
// Previously attempted and failed - clear the record and retry.
|
||||
await client.query(
|
||||
`DELETE FROM "_migration" WHERE version = $1 AND success = false`,
|
||||
[migration.version],
|
||||
@@ -188,7 +188,7 @@ export async function applyMigrations(
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[migrate] Applying ${migration.version} — ${migration.description}...`,
|
||||
`[migrate] Applying ${migration.version} - ${migration.description}...`,
|
||||
);
|
||||
|
||||
const start = process.hrtime.bigint();
|
||||
@@ -334,7 +334,7 @@ export async function revertLastMigration(
|
||||
|
||||
const downSql = readFileSync(downPath, "utf8");
|
||||
|
||||
console.log(`[migrate] Reverting ${latestVersion} — ${latest.description}...`);
|
||||
console.log(`[migrate] Reverting ${latestVersion} - ${latest.description}...`);
|
||||
|
||||
const start = process.hrtime.bigint();
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ export default interface PbComposedFromTable {
|
||||
pb_id: ColumnType<pb_row_id, pb_row_id, pb_row_id>;
|
||||
|
||||
score_id: ColumnType<score_id, score_id, score_id>;
|
||||
|
||||
merge_name: ColumnType<string, string, string>;
|
||||
}
|
||||
|
||||
export type PbComposedFrom = Selectable<PbComposedFromTable>;
|
||||
|
||||
@@ -26,7 +26,7 @@ const base = tseslint.config(
|
||||
...tseslint.configs.recommended,
|
||||
pluginPerfectionist.configs["recommended-natural"],
|
||||
{
|
||||
name: "zenith/base",
|
||||
name: "tachi/base",
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
plugins: {
|
||||
import: pluginImport,
|
||||
@@ -141,10 +141,17 @@ const base = tseslint.config(
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tachi/test-files",
|
||||
files: ["**/*.test.ts"],
|
||||
rules: {
|
||||
"no-await-in-loop": "off",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const node = /** @type {import("@typescript-eslint/utils/ts-eslint").FlatConfig.Config} */ ({
|
||||
name: "zenith/node",
|
||||
name: "tachi/node",
|
||||
files: ["**/*.{js,mjs,cjs,ts,tsx}"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
@@ -154,7 +161,7 @@ const node = /** @type {import("@typescript-eslint/utils/ts-eslint").FlatConfig.
|
||||
});
|
||||
|
||||
const react = /** @type {import("@typescript-eslint/utils/ts-eslint").FlatConfig.Config} */ ({
|
||||
name: "zenith/react",
|
||||
name: "tachi/react",
|
||||
files: ["**/*.tsx"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
@@ -211,8 +218,10 @@ const react = /** @type {import("@typescript-eslint/utils/ts-eslint").FlatConfig
|
||||
},
|
||||
});
|
||||
|
||||
// unused (from zenith), we don't use remix. We don't even use remix in zenith either
|
||||
// because it got renamed to rrv7.
|
||||
const reactRemix = /** @type {import("@typescript-eslint/utils/ts-eslint").FlatConfig.Config} */ ({
|
||||
name: "zenith/react-remix",
|
||||
name: "tachi/react-remix",
|
||||
languageOptions: {
|
||||
// @ts-expect-error it is definitely not undefined
|
||||
parserOptions: react.languageOptions.parserOptions,
|
||||
|
||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/test-utils/**",
|
||||
// Barrel only re-exports algorithms — no executable lines to cover.
|
||||
// Barrel only re-exports algorithms - no executable lines to cover.
|
||||
"src/index.ts",
|
||||
// Type-only / unused by current test surface (Tap nyc also did not require these).
|
||||
"src/util/types.ts",
|
||||
|
||||
@@ -20,9 +20,7 @@ const options = program.opts();
|
||||
const DEBUG = options.debug;
|
||||
|
||||
const db = new Database(options.db, { readonly: true });
|
||||
const dbRows = db
|
||||
.query(`SELECT * FROM Charts WHERE path LIKE '%${options.filter}%'`)
|
||||
.all();
|
||||
const dbRows = db.query(`SELECT * FROM Charts WHERE path LIKE '%${options.filter}%'`).all();
|
||||
console.log(`Found ${dbRows.length} charts.`);
|
||||
|
||||
const songs = ReadCollection("songs-usc.json");
|
||||
|
||||
@@ -35,7 +35,7 @@ function sqlStr(value: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* `jsonb_array_elements` does not work on JSON objects — rewrite legacy elemMatch SQL
|
||||
* `jsonb_array_elements` does not work on JSON objects - rewrite legacy elemMatch SQL
|
||||
* to key lookup (must match `5-folders-to-sql-queries.ts`).
|
||||
*/
|
||||
function rewriteFolderWhereTableFoldersElemExists(where: string): string | null {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# tachi-seeds-webui
|
||||
|
||||
The seeds workstation. Browse, query, diff over git history — and, in
|
||||
The seeds workstation. Browse, query, diff over git history - and, in
|
||||
localdev, edit `db/seeds/*.json` directly.
|
||||
|
||||
## Running
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function listBranches(
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// detached head — leave current null.
|
||||
// detached head - leave current null.
|
||||
}
|
||||
|
||||
return { branches, current };
|
||||
@@ -129,7 +129,7 @@ export async function statusPorcelain(
|
||||
branch = m?.[1] ?? null;
|
||||
continue;
|
||||
}
|
||||
// Porcelain format: " XY path" — strip the two-char status + space.
|
||||
// Porcelain format: " XY path" - strip the two-char status + space.
|
||||
changed.push(line.slice(3));
|
||||
}
|
||||
return { branch, changed, hasUncommitted: changed.length > 0 };
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "./util/git";
|
||||
import { applyPatch, type JsonPatch } from "./util/json-patch";
|
||||
|
||||
// vite-plugin-seeds-dev — mounts /__seeds/* dev-only endpoints against the
|
||||
// vite-plugin-seeds-dev - mounts /__seeds/* dev-only endpoints against the
|
||||
// local repo's db/seeds directory so the seeds-webui can:
|
||||
//
|
||||
// - GET /__seeds/ping feature probe
|
||||
|
||||
@@ -22,7 +22,7 @@ const queryClient = new QueryClient({
|
||||
|
||||
// Load the editing routes lazily via a single dynamic import. The import is
|
||||
// only reached when EDIT_MODE is true; since EDIT_MODE is replaced with the
|
||||
// literal `false` during `vite build`, rollup drops the entire expression —
|
||||
// literal `false` during `vite build`, rollup drops the entire expression -
|
||||
// which means `editRoutes.ts` and every page it references is never emitted
|
||||
// into the prod bundle.
|
||||
type EditRoute = { component: React.ComponentType; path: string };
|
||||
|
||||
@@ -126,14 +126,14 @@ function FieldTable({ fields }: { fields: FieldDelta[] }) {
|
||||
<td className="diff-fields-path mono">{f.path}</td>
|
||||
<td className="diff-fields-before">
|
||||
{f.kind === "added" ? (
|
||||
<span className="text-muted">—</span>
|
||||
<span className="text-muted">-</span>
|
||||
) : (
|
||||
<pre className="mono">{formatValue(f.before)}</pre>
|
||||
)}
|
||||
</td>
|
||||
<td className="diff-fields-after">
|
||||
{f.kind === "removed" ? (
|
||||
<span className="text-muted">—</span>
|
||||
<span className="text-muted">-</span>
|
||||
) : (
|
||||
<pre className="mono">{formatValue(f.after)}</pre>
|
||||
)}
|
||||
|
||||
@@ -91,7 +91,7 @@ export function CommitSparkline({ commits, days = 90 }: CommitSparklineProps) {
|
||||
y={y}
|
||||
>
|
||||
<title>
|
||||
{b.day} — {b.count} commit{b.count === 1 ? "" : "s"}
|
||||
{b.day} - {b.count} commit{b.count === 1 ? "" : "s"}
|
||||
</title>
|
||||
</rect>
|
||||
);
|
||||
|
||||
@@ -39,7 +39,7 @@ interface ResultTableProps {
|
||||
// Pass `expandedContent` for custom content (e.g. a diff); otherwise the
|
||||
// detail is the whole row as pretty-printed JSON.
|
||||
// - `rowDetailMode="inline"` (with `expandedContent`) shows that detail
|
||||
// under every row with no chevron or click — e.g. Bulk edit previews.
|
||||
// under every row with no chevron or click - e.g. Bulk edit previews.
|
||||
export function ResultTable({
|
||||
result,
|
||||
maxRows = 500,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { type z } from "zod";
|
||||
//
|
||||
// Replaces the previous schema-driven field-tree form. Seeds documents are
|
||||
// complex enough (discriminated unions, per-game chart data, etc.) that a
|
||||
// generated form was always going to be leaky — it's cleaner to just edit
|
||||
// generated form was always going to be leaky - it's cleaner to just edit
|
||||
// the raw JSON and let the schema tell you exactly what's wrong.
|
||||
//
|
||||
// Behaviour:
|
||||
|
||||
@@ -7,7 +7,7 @@ import { basicSetup, EditorView } from "codemirror";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Replaces the default CodeMirror colors in dark mode — `defaultHighlightStyle` uses #708
|
||||
* Replaces the default CodeMirror colors in dark mode - `defaultHighlightStyle` uses #708
|
||||
* (purple) for keywords, which is nearly unreadable on the seeds-webui near-black page.
|
||||
* When `themeType: "dark"`, this becomes the only non-fallback highlighter, so the purple
|
||||
* palette is not used.
|
||||
@@ -33,7 +33,7 @@ const sqlEditorHighlightDark = HighlightStyle.define(
|
||||
{ themeType: "dark" },
|
||||
);
|
||||
|
||||
/** `data-bs-theme` on <html> — must drive CodeMirror's `EditorView.darkTheme` or the caret stays light-theme (black) on a dark page. */
|
||||
/** `data-bs-theme` on <html> - must drive CodeMirror's `EditorView.darkTheme` or the caret stays light-theme (black) on a dark page. */
|
||||
function useBootstrapPageDark(): boolean {
|
||||
const [isDark, setIsDark] = useState(
|
||||
() => document.documentElement.getAttribute("data-bs-theme") !== "light",
|
||||
@@ -64,7 +64,7 @@ export type SqlEditorProps = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
/** Surfaces, caret, and panels — all use Bootstrap CSS variables so light/dark track the page. */
|
||||
/** Surfaces, caret, and panels - all use Bootstrap CSS variables so light/dark track the page. */
|
||||
// Use baseTheme: `&light` / `&dark` are only expanded in buildTheme(..., lightDarkIDs), not in EditorView.theme().
|
||||
const surfaceTheme = EditorView.baseTheme({
|
||||
"&": {
|
||||
@@ -75,7 +75,7 @@ const surfaceTheme = EditorView.baseTheme({
|
||||
fontFeatureSettings: '"liga" 0, "calt" 0',
|
||||
},
|
||||
// Caret: drawSelection defaults to black / #ddd for &dark, but the editor was not getting
|
||||
// `EditorView.darkTheme` — keep an explicit color so the caret always matches body text.
|
||||
// `EditorView.darkTheme` - keep an explicit color so the caret always matches body text.
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeft: "1.2px solid var(--bs-body-color)",
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// This is a *build-time* flag set in vite.config.ts. In a production build
|
||||
// (`vite build`) it is the literal `false`, so the bundler tree-shakes every
|
||||
// branch guarded by `EDIT_MODE`. That's how we keep the static prod bundle
|
||||
// absolutely incapable of writing to disk — even if someone self-hosted it.
|
||||
// absolutely incapable of writing to disk - even if someone self-hosted it.
|
||||
//
|
||||
// `hasDevTransport` (see #lib/transport/transport) additionally probes
|
||||
// /__seeds/ping at runtime before enabling the edit UI, in case a dev-built
|
||||
|
||||
@@ -46,7 +46,7 @@ export function fieldDeltas(a: Row, b: Row, prefix = ""): FieldDelta[] {
|
||||
out.push({ before: av, kind: "removed", path });
|
||||
continue;
|
||||
}
|
||||
// Recurse into plain objects (but not arrays — arrays are treated as
|
||||
// Recurse into plain objects (but not arrays - arrays are treated as
|
||||
// atomic scalar values, which is the right call for things like
|
||||
// `tierlistData` or chart `data`).
|
||||
if (
|
||||
@@ -196,7 +196,7 @@ export function singleDocumentDiff(
|
||||
|
||||
export function formatValue(v: unknown): string {
|
||||
if (v === undefined) {
|
||||
return "—";
|
||||
return "-";
|
||||
}
|
||||
if (v === null) {
|
||||
return "null";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export type Row = Record<string, unknown>;
|
||||
|
||||
// Candidate primary keys, in priority order. The first one present on a row is
|
||||
// used. `id` is listed before `songID` because chart documents carry both —
|
||||
// used. `id` is listed before `songID` because chart documents carry both -
|
||||
// we want to key a chart by *its* id, not by the song it belongs to.
|
||||
export const PK_KEYS = [
|
||||
"id",
|
||||
|
||||
@@ -43,7 +43,7 @@ export function schemaForCollection(name: string): z.ZodType<unknown> | null {
|
||||
return SEEDS_BMS_COURSE_DOCUMENT_SCHEMA as z.ZodType<unknown>;
|
||||
}
|
||||
|
||||
// charts-${V3Game}.json — filenames mirror `db/seeds/` and match ALL_GAMES
|
||||
// charts-${V3Game}.json - filenames mirror `db/seeds/` and match ALL_GAMES
|
||||
// keys (e.g. iidx-sp, bms-14k, gitadora-dora). Do not split on the last
|
||||
// hyphen; that would turn iidx-sp into iidx and miss the schema map.
|
||||
if (stripped.startsWith("charts-")) {
|
||||
|
||||
@@ -12,8 +12,8 @@ export type SeedDocSummaryOpts = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Human-readable one-liner for a seeds row — same rules as the collection
|
||||
* browser list (FormatChart for charts with a song, title — artist for songs,
|
||||
* Human-readable one-liner for a seeds row - same rules as the collection
|
||||
* browser list (FormatChart for charts with a song, title - artist for songs,
|
||||
* etc.).
|
||||
*/
|
||||
export function prettySeedDocSummary(
|
||||
@@ -52,7 +52,7 @@ export function prettySeedDocSummary(
|
||||
case "songs": {
|
||||
const title = str(doc.title);
|
||||
const artist = str(doc.artist);
|
||||
return artist ? `${title} — ${artist}` : title;
|
||||
return artist ? `${title} - ${artist}` : title;
|
||||
}
|
||||
case "folders":
|
||||
case "tables": {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getSqlite } from "./client";
|
||||
|
||||
// Fast content hash for JSON payloads. We hash the serialised string because
|
||||
// getCollection returns parsed JSON; this matches what scripts/bundle-current-seeds
|
||||
// stores (sha256(prefix-16) of the raw file bytes — close enough for change detection).
|
||||
// stores (sha256(prefix-16) of the raw file bytes - close enough for change detection).
|
||||
async function hashJson(value: unknown): Promise<string> {
|
||||
const s = JSON.stringify(value);
|
||||
const buf = new TextEncoder().encode(s);
|
||||
@@ -36,7 +36,7 @@ export async function* buildSqliteFromTransport(
|
||||
const name = names[i]!;
|
||||
const rows = await transport.getCollection(name, rev);
|
||||
const hash = await hashJson(rows);
|
||||
// When rev !== undefined we *always* re-ingest — the caller is looking at
|
||||
// When rev !== undefined we *always* re-ingest - the caller is looking at
|
||||
// a specific commit, not the working copy, and we don't cache historical
|
||||
// loads to avoid OPFS bloat.
|
||||
if (rev === undefined && meta[name] === hash) {
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
// Strategy: *per-collection* tables mirror the on-disk files exactly
|
||||
// (so a row in `charts_iidx_sp` corresponds 1:1 to a JSON object in
|
||||
// `db/seeds/charts-iidx-sp.json`). We project the scalar fields we
|
||||
// query on into columns and keep the rest as a JSON blob — SQLite's
|
||||
// query on into columns and keep the rest as a JSON blob - SQLite's
|
||||
// `json_extract` makes that queryable when needed.
|
||||
//
|
||||
// Cross-cutting questions ("how many charts does iidx-sp have across all
|
||||
// versions?") get answered by materialised views built over per-collection
|
||||
// tables. We build the union of charts/songs lazily — i.e. the view DDL
|
||||
// tables. We build the union of charts/songs lazily - i.e. the view DDL
|
||||
// references every `charts_<game>` table that has been created.
|
||||
//
|
||||
// Table name convention: lowercase, '-' -> '_', strip '.json'.
|
||||
|
||||
@@ -77,7 +77,7 @@ export function makeDevTransport(): SeedsTransport {
|
||||
};
|
||||
}
|
||||
|
||||
// Server-Sent Events helper — the dev plugin streams stdout/stderr line-by-line
|
||||
// Server-Sent Events helper - the dev plugin streams stdout/stderr line-by-line
|
||||
// plus a final `exit` event, so we wrap it as an AsyncIterable<RunEvent>.
|
||||
async function* streamSSE<T>(pathname: string, body: unknown): AsyncIterable<T> {
|
||||
const res = await fetch(`${BASE}${pathname}`, {
|
||||
|
||||
@@ -58,7 +58,7 @@ export type JsonPatch = JsonPatchOp[];
|
||||
export interface SeedsTransport {
|
||||
readonly mode: "dev" | "github";
|
||||
|
||||
// Read-side — always available.
|
||||
// Read-side - always available.
|
||||
listCollections(): Promise<CollectionName[]>;
|
||||
listBranches(): Promise<{ branches: Branch[]; current: Branch | null }>;
|
||||
listCommits(opts: { branch?: string; cursor?: string; file?: string }): Promise<CommitPage>;
|
||||
@@ -67,7 +67,7 @@ export interface SeedsTransport {
|
||||
// branch HEAD" in github.
|
||||
getCollection(name: CollectionName, rev?: string): Promise<unknown[]>;
|
||||
|
||||
// Write-side — populated only in dev mode.
|
||||
// Write-side - populated only in dev mode.
|
||||
writeCollection?: (name: CollectionName, patch: JsonPatch) => Promise<void>;
|
||||
runSort?: () => Promise<void>;
|
||||
gitStatus?: () => Promise<GitStatus>;
|
||||
|
||||
@@ -174,7 +174,7 @@ export function Bulk() {
|
||||
}
|
||||
}
|
||||
if (rejections.length > 0) {
|
||||
setError(`Bulk merge rejected — would not satisfy schema:\n${rejections.join("\n")}`);
|
||||
setError(`Bulk merge rejected - would not satisfy schema:\n${rejections.join("\n")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ export function Collection() {
|
||||
|
||||
{!schema && EDIT_MODE ? (
|
||||
<div className="alert alert-warning mono">
|
||||
No zod schema found for this collection — editing is unavailable.
|
||||
No zod schema found for this collection - editing is unavailable.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ function mergeSongRowsById(baseSongs: Row[], headSongs: Row[]): Map<string, Row>
|
||||
|
||||
// seeds.tachi.ac always points at the canonical upstream. We use this for
|
||||
// every GitHub link in the diff header (commits, compare, PRs). This is a
|
||||
// deliberate hardcode — the dev transport doesn't know what remote the
|
||||
// deliberate hardcode - the dev transport doesn't know what remote the
|
||||
// local clone is pointing at, and for the hosted site it's always this repo.
|
||||
const GITHUB_REPO_URL = "https://github.com/zkldi/Tachi";
|
||||
|
||||
@@ -341,7 +341,7 @@ function CommitHeader({
|
||||
</div>
|
||||
<div className="commit-header-meta">
|
||||
<span>
|
||||
<strong>{commit?.author.name ?? "—"}</strong>
|
||||
<strong>{commit?.author.name ?? "-"}</strong>
|
||||
{commit?.author.email ? (
|
||||
<span className="text-muted"> <{commit.author.email}></span>
|
||||
) : null}
|
||||
|
||||
@@ -94,7 +94,7 @@ async function computeDocPair(draft: Draft): Promise<DocPair | null> {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Draft item — op / time / discard in a slim bar; diff card always visible
|
||||
// Draft item - op / time / discard in a slim bar; diff card always visible
|
||||
// (same id=…, optional pretty title, field table, Show JSON as on /diff).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -272,7 +272,7 @@ export function Drafts() {
|
||||
) : null}
|
||||
</h2>
|
||||
<p className="page-subtitle">
|
||||
Staged edits. Apply to write them to disk — seeds are re-sorted
|
||||
Staged edits. Apply to write them to disk - seeds are re-sorted
|
||||
automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Tachi brand pink — matches the .st1 fill in logo.svg.
|
||||
// Tachi brand pink - matches the .st1 fill in logo.svg.
|
||||
$tachi-pink: #e61c6e;
|
||||
|
||||
// Bootstrap overrides (must come before `@import "bootstrap-scss/bootstrap"`).
|
||||
@@ -31,7 +31,7 @@ $font-weight-bold: 600;
|
||||
|
||||
$min-contrast-ratio: 3;
|
||||
|
||||
// Slightly warmer/cooler grays on each theme — matches the vibe of tachi-client.
|
||||
// Slightly warmer/cooler grays on each theme - matches the vibe of tachi-client.
|
||||
$body-bg-dark: #131313;
|
||||
$body-tertiary-bg-dark: #1a1a1a;
|
||||
$body-secondary-bg-dark: #242424;
|
||||
|
||||
@@ -253,7 +253,7 @@ code {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Git status strip — appears under the header in dev mode.
|
||||
// Git status strip - appears under the header in dev mode.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
.git-strip {
|
||||
@@ -519,7 +519,7 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
// Row-expand column — narrow, center-aligned, no hover bg.
|
||||
// Row-expand column - narrow, center-aligned, no hover bg.
|
||||
.result-col-expand {
|
||||
width: 2rem;
|
||||
padding: 0.35rem 0.25rem 0.35rem 0.5rem;
|
||||
@@ -709,7 +709,7 @@ code {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skeleton loading placeholder — used anywhere a list of rows is loading.
|
||||
// Skeleton loading placeholder - used anywhere a list of rows is loading.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
@@ -791,7 +791,7 @@ code {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ingest toast — pinned bottom-right overlay that reports sqlite build
|
||||
// Ingest toast - pinned bottom-right overlay that reports sqlite build
|
||||
// progress from anywhere in the app. Non-blocking; slides up on mount and
|
||||
// fades out once the database is ready.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -934,7 +934,7 @@ code {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diff view — card-per-row with field-level highlighting. Readable over the
|
||||
// Diff view - card-per-row with field-level highlighting. Readable over the
|
||||
// previous ellipsised table.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1296,7 +1296,7 @@ $diff-chg-border: rgba(245, 177, 76, 0.4);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed document list — Collection page renderer.
|
||||
// Seed document list - Collection page renderer.
|
||||
//
|
||||
// Each seed document is a collapsible row: a one-line summary header with
|
||||
// optional edit/delete buttons, and a pretty-printed JSON block when open.
|
||||
@@ -1597,7 +1597,7 @@ $diff-chg-border: rgba(245, 177, 76, 0.4);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Floating drafts bar — pinned bottom-left, shown when there are staged edits
|
||||
// Floating drafts bar - pinned bottom-left, shown when there are staged edits
|
||||
// and the user is not already on /drafts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ import { seedsDevPlugin } from "./dev/vite-plugin-seeds-dev";
|
||||
// In `vite dev` the seedsDevPlugin mounts /__seeds/* endpoints against the
|
||||
// local repo's db/seeds/ so the UI can edit, query git history, and run
|
||||
// the seeds-scripts tests. These endpoints are NOT present in the
|
||||
// production build — all references to them are gated behind
|
||||
// production build - all references to them are gated behind
|
||||
// import.meta.env.VITE_SEEDS_EDIT_MODE and tree-shaken out.
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "../..");
|
||||
|
||||
/** Hoisted path for a workspace @codemirror/* package (single instance — avoids "Unrecognized extension value" from nested node_modules). */
|
||||
/** Hoisted path for a workspace @codemirror/* package (single instance - avoids "Unrecognized extension value" from nested node_modules). */
|
||||
const cmPackage = (name: string) => path.join(REPO_ROOT, "node_modules", "@codemirror", name);
|
||||
|
||||
const codemirrorSingleInstanceAliases: {
|
||||
@@ -35,7 +35,7 @@ const lezerSingleInstanceAliases: {
|
||||
}));
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
// Edit mode is only on for `vite dev` — `vite build` and `vite preview`
|
||||
// Edit mode is only on for `vite dev` - `vite build` and `vite preview`
|
||||
// produce a read-only bundle suitable for hosting at seeds.tachi.ac.
|
||||
define: {
|
||||
"import.meta.env.VITE_SEEDS_EDIT_MODE": JSON.stringify(command === "serve"),
|
||||
@@ -67,7 +67,7 @@ export default defineConfig(({ command }) => ({
|
||||
// In prod builds, short-circuit modules that are only reachable
|
||||
// when EDIT_MODE is on. `editRoutes` hosts the edit-only pages
|
||||
// and `schemas` pulls in the whole tachi-common game config tree
|
||||
// for zod introspection — we don't want either of those shipping
|
||||
// for zod introspection - we don't want either of those shipping
|
||||
// to seeds.tachi.ac.
|
||||
{
|
||||
name: "seeds-webui:strip-edit-routes",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Dockerfile
|
||||
|
||||
# Env files — mirror repo root `.dockerignore` when this package is build context alone.
|
||||
# Env files - mirror repo root `.dockerignore` when this package is build context alone.
|
||||
.env
|
||||
.env.*
|
||||
**/.env
|
||||
|
||||
@@ -13,7 +13,6 @@ MIGRATIONS_DIR=/tachi/db/migrations
|
||||
LOG_LEVEL=debug
|
||||
|
||||
TACHI_EMAIL_FROM=dev@localhost
|
||||
TACHI_EMAIL_AUTH_POSTMARK=false
|
||||
TACHI_EMAIL_HOST=tachi-mailpit
|
||||
TACHI_EMAIL_PORT=1025
|
||||
TACHI_EMAIL_SECURE=false
|
||||
|
||||
@@ -53,7 +53,6 @@ TACHI_CDN_SAVE_LOCATION_PRIVATE_REGION=
|
||||
|
||||
# Email (required)
|
||||
TACHI_EMAIL_FROM=
|
||||
TACHI_EMAIL_AUTH_POSTMARK=
|
||||
TACHI_EMAIL_HOST=
|
||||
TACHI_EMAIL_PORT=
|
||||
TACHI_EMAIL_SECURE=
|
||||
@@ -69,7 +68,7 @@ TACHI_MYT_API_HOST=
|
||||
TACHI_ARC_AUTH_TOKEN=
|
||||
TACHI_MYT_AUTH_TOKEN=
|
||||
|
||||
# OAuth2 — set all 3 for a provider or none
|
||||
# OAuth2 - set all 3 for a provider or none
|
||||
TACHI_FLO_OAUTH2_CLIENT_ID=
|
||||
TACHI_FLO_OAUTH2_CLIENT_SECRET=
|
||||
TACHI_FLO_OAUTH2_REDIRECT_URI=
|
||||
@@ -80,7 +79,7 @@ TACHI_MIN_OAUTH2_CLIENT_ID=
|
||||
TACHI_MIN_OAUTH2_CLIENT_SECRET=
|
||||
TACHI_MIN_OAUTH2_REDIRECT_URI=
|
||||
|
||||
# CG — set both for a suffix or neither
|
||||
# CG - set both for a suffix or neither
|
||||
TACHI_CG_DEV_API_KEY=
|
||||
TACHI_CG_DEV_URL=
|
||||
TACHI_CG_NAG_API_KEY=
|
||||
@@ -88,7 +87,7 @@ TACHI_CG_NAG_URL=
|
||||
TACHI_CG_GAN_API_KEY=
|
||||
TACHI_CG_GAN_URL=
|
||||
|
||||
# Seeds (optional) — set TACHI_SEEDS_TYPE to LOCAL_FILES or GIT_REPO to enable
|
||||
# Seeds (optional) - set TACHI_SEEDS_TYPE to LOCAL_FILES or GIT_REPO to enable
|
||||
TACHI_SEEDS_TYPE=
|
||||
TACHI_SEEDS_PATH=
|
||||
TACHI_SEEDS_REPO_URL=
|
||||
@@ -96,7 +95,7 @@ TACHI_SEEDS_USER_NAME=
|
||||
TACHI_SEEDS_USER_EMAIL=
|
||||
TACHI_SEEDS_BRANCH=
|
||||
|
||||
# Invite codes — set all 3 or none
|
||||
# Invite codes - set all 3 or none
|
||||
TACHI_INVITE_CODE_BATCH_SIZE=
|
||||
TACHI_INVITE_CODE_INVITE_CAP=
|
||||
TACHI_INVITE_CODE_BETA_USER_BONUS=
|
||||
|
||||
@@ -42,7 +42,6 @@ TACHI_GAME_GROUPS=iidx,museca,sdvx,bms,chunithm,usc,wacca,popn,jubeat,pms,maimai
|
||||
TACHI_IMPORT_TYPES=file/eamusement-iidx-csv,file/batch-manual,file/solid-state-squad,file/pli-iidx-csv,ir/direct-manual,ir/barbatos,ir/fervidex,ir/fervidex-static,ir/beatoraja,ir/usc,ir/kshook-sv6c,api/eag-iidx,api/eag-sdvx,api/flo-iidx,api/flo-sdvx,api/min-sdvx
|
||||
|
||||
TACHI_EMAIL_FROM=test@example.com
|
||||
TACHI_EMAIL_AUTH_POSTMARK=false
|
||||
TACHI_EMAIL_HOST=127.0.0.1
|
||||
TACHI_EMAIL_PORT=1025
|
||||
TACHI_EMAIL_SECURE=false
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
files: "src/test-utils/single-process-tap.ts"
|
||||
test-env:
|
||||
- NODE_ENV=test
|
||||
- PORT=8080
|
||||
- LOG_LEVEL=info
|
||||
# On a local machine, these tests run in under 2-3 minutes.
|
||||
# On github actions, these tests can take an inordinate amount of time.
|
||||
# The timeout is set to 10 minutes, which is ridiculous.
|
||||
# Doesn't matter for real machines.
|
||||
timeout: 600
|
||||
ts: true
|
||||
before: "./src/test-utils/hack-setup.js"
|
||||
check-coverage: true
|
||||
branches: 30
|
||||
functions: 80
|
||||
lines: 80
|
||||
statements: 80
|
||||
reporter: terse
|
||||
@@ -31,6 +31,6 @@ export const ACTION_ResendVerifyEmail = MakeAction("RESEND_VERIFY_EMAIL", async
|
||||
|
||||
const { text, html } = EmailFormatVerifyEmail(taker.acct.username, newToken);
|
||||
|
||||
void SendEmail(verifyInfo.email, "Email Verification", html, text);
|
||||
await SendEmail(verifyInfo.email, "Email Verification", html, text);
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import DB from "#services/pg/db";
|
||||
import { seedInvite, seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ANON_ACTION_Register } from "./register";
|
||||
|
||||
@@ -217,6 +218,52 @@ describe("ANON_ACTION_Register", () => {
|
||||
expect(user).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Sequence gaps ─────────────────────────────────────────────────────────
|
||||
|
||||
it("does not burn sequence values on failed registrations with bad invite codes", async () => {
|
||||
const first = await ANON_ACTION_Register(taker, {
|
||||
username: "firstuser",
|
||||
"!password": "securepassword",
|
||||
email: "first@example.com",
|
||||
captcha: "test",
|
||||
inviteCode,
|
||||
});
|
||||
|
||||
// Need a fresh invite for the next successful signup.
|
||||
const { id: seedId } = await seedUser({
|
||||
username: "inviter2",
|
||||
email: "inviter2@example.com",
|
||||
});
|
||||
const secondInvite = await seedInvite(seedId, "SECOND_INVITE");
|
||||
|
||||
// 10 failed registrations — bad invite code but unique username/email,
|
||||
// so they enter the transaction and reach AddNewUser before failing.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await expect(
|
||||
ANON_ACTION_Register(taker, {
|
||||
username: `baduser${i}`,
|
||||
"!password": "securepassword",
|
||||
email: `bad${i}@example.com`,
|
||||
captcha: "test",
|
||||
inviteCode: "BOGUS_CODE",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
}
|
||||
|
||||
const second = await ANON_ACTION_Register(taker, {
|
||||
username: "seconduser",
|
||||
"!password": "securepassword",
|
||||
email: "second@example.com",
|
||||
captcha: "test",
|
||||
inviteCode: secondInvite,
|
||||
});
|
||||
|
||||
// If sequences are gap-free, the second real user should be first.id + 1
|
||||
// (skipping only the seedUser helper we inserted, which also burns one).
|
||||
// With BIGSERIAL, this FAILS: the 10 rolled-back txns each burned a value.
|
||||
expect(second.userID).toBe(first.userID + 2);
|
||||
});
|
||||
|
||||
// ── Audit log ──────────────────────────────────────────────────────────────
|
||||
|
||||
it("writes a BAD action row when the username is already taken", async () => {
|
||||
@@ -276,3 +323,94 @@ describe("ANON_ACTION_Register", () => {
|
||||
expect(input).not.toHaveProperty("!password");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bootstrap invite (INVITE_ADMIN_INITIAL_INVITE_CODE) ──────────────────────
|
||||
|
||||
describe("ANON_ACTION_Register - bootstrap invite", () => {
|
||||
const taker = { ip: "127.0.0.1" };
|
||||
const BOOTSTRAP_CODE = "BOOTSTRAP_SECRET_FOR_TESTS";
|
||||
|
||||
let originalBootstrap: typeof ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE;
|
||||
|
||||
beforeEach(() => {
|
||||
originalBootstrap = ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE;
|
||||
ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE = BOOTSTRAP_CODE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE = originalBootstrap;
|
||||
});
|
||||
|
||||
it("creates an admin user when the instance has no accounts", async () => {
|
||||
const result = await ANON_ACTION_Register(taker, {
|
||||
username: "firstadmin",
|
||||
"!password": "securepassword",
|
||||
email: "admin@example.com",
|
||||
captcha: "test",
|
||||
inviteCode: BOOTSTRAP_CODE,
|
||||
});
|
||||
|
||||
const account = await DB.selectFrom("account")
|
||||
.select(["account.id", "account.auth_level"])
|
||||
.where("account.id", "=", result.userID)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(account.auth_level).toBe("admin");
|
||||
});
|
||||
|
||||
it("does not consume any priv_invite row", async () => {
|
||||
await ANON_ACTION_Register(taker, {
|
||||
username: "firstadmin",
|
||||
"!password": "securepassword",
|
||||
email: "admin@example.com",
|
||||
captcha: "test",
|
||||
inviteCode: BOOTSTRAP_CODE,
|
||||
});
|
||||
|
||||
const invites = await DB.selectFrom("priv_invite")
|
||||
.select(DB.fn.countAll().as("count"))
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(Number(invites.count)).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects the bootstrap code when an account already exists", async () => {
|
||||
await seedUser();
|
||||
|
||||
await expect(
|
||||
ANON_ACTION_Register(taker, {
|
||||
username: "seconduser",
|
||||
"!password": "securepassword",
|
||||
email: "second@example.com",
|
||||
captcha: "test",
|
||||
inviteCode: BOOTSTRAP_CODE,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
});
|
||||
|
||||
it("rejects a wrong code even on an empty instance", async () => {
|
||||
await expect(
|
||||
ANON_ACTION_Register(taker, {
|
||||
username: "firstadmin",
|
||||
"!password": "securepassword",
|
||||
email: "admin@example.com",
|
||||
captcha: "test",
|
||||
inviteCode: "WRONG_CODE",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("falls through to normal invite validation when env is unset", async () => {
|
||||
ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE = undefined;
|
||||
|
||||
await expect(
|
||||
ANON_ACTION_Register(taker, {
|
||||
username: "firstadmin",
|
||||
"!password": "securepassword",
|
||||
email: "admin@example.com",
|
||||
captcha: "test",
|
||||
inviteCode: "NONEXISTENT",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,25 @@ import DB from "#services/pg/db";
|
||||
import { Random20Hex } from "#utils/misc";
|
||||
import { CheckIfEmailInUse, GetUserCaseInsensitive } from "#utils/user";
|
||||
import { ExpectedErr, log } from "bliss";
|
||||
import crypto from "crypto";
|
||||
import { sql } from "kysely";
|
||||
import { type UserDocument } from "tachi-common";
|
||||
|
||||
/** Namespace for the advisory lock that serialises bootstrap-invite registrations. */
|
||||
const BOOTSTRAP_INVITE_ADVISORY_KEY1 = 0x42_6f_6f_74; // "Boot"
|
||||
const BOOTSTRAP_INVITE_ADVISORY_KEY2 = 0x49_6e_76_74; // "Invt"
|
||||
|
||||
function bootstrapCodeMatches(input: string, expected: string): boolean {
|
||||
const a = Buffer.from(input, "utf8");
|
||||
const b = Buffer.from(expected, "utf8");
|
||||
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export const ANON_ACTION_Register = MakeAnonAction(
|
||||
"REGISTER",
|
||||
async (taker, { email, "!password": password, inviteCode, username, captcha }) => {
|
||||
@@ -44,10 +61,6 @@ export const ANON_ACTION_Register = MakeAnonAction(
|
||||
}
|
||||
|
||||
const newUser = await DB.transaction().execute(async (txn): Promise<UserDocument> => {
|
||||
// if we get to this point, We're good to create the user.
|
||||
|
||||
const { newUser, newSettings: _ } = await AddNewUser(txn, username, password, email);
|
||||
|
||||
if (ServerConfig.INVITE_CODE_CONFIG) {
|
||||
if (!inviteCode) {
|
||||
throw new ExpectedErr(
|
||||
@@ -55,20 +68,61 @@ export const ANON_ACTION_Register = MakeAnonAction(
|
||||
"No invite code given, yet the server uses invites.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isBootstrapInvite =
|
||||
ServerConfig.INVITE_CODE_CONFIG &&
|
||||
ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE &&
|
||||
inviteCode !== null &&
|
||||
bootstrapCodeMatches(inviteCode, ServerConfig.INVITE_ADMIN_INITIAL_INVITE_CODE);
|
||||
|
||||
if (isBootstrapInvite) {
|
||||
await sql`SELECT pg_advisory_xact_lock(${BOOTSTRAP_INVITE_ADVISORY_KEY1}, ${BOOTSTRAP_INVITE_ADVISORY_KEY2})`.execute(
|
||||
txn,
|
||||
);
|
||||
|
||||
const { count } = await txn
|
||||
.selectFrom("account")
|
||||
.select(txn.fn.countAll().as("count"))
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
if (Number(count) > 0) {
|
||||
log.info("Bootstrap invite used but instance already has users.");
|
||||
throw new ExpectedErr(400, `Invalid invite code given: ${inviteCode}.`);
|
||||
}
|
||||
} else if (ServerConfig.INVITE_CODE_CONFIG) {
|
||||
// Validate the invite code BEFORE creating the user so a bad code
|
||||
// never burns a sequence value on `account.id`.
|
||||
// FOR UPDATE locks the row so a concurrent signup can't consume
|
||||
// the same code between our check and the update below.
|
||||
const inviteCodeDoc = await txn
|
||||
.selectFrom("priv_invite")
|
||||
.select("code")
|
||||
.where("code", "=", inviteCode)
|
||||
.where("consumed", "=", false)
|
||||
.select("priv_invite.code")
|
||||
.where("priv_invite.code", "=", inviteCode!)
|
||||
.where("priv_invite.consumed", "=", false)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!inviteCodeDoc) {
|
||||
log.info(`Invalid invite code given: ${inviteCode}.`);
|
||||
throw new ExpectedErr(400, `Invalid invite code given: ${inviteCode}.`);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Consumed invite ${inviteCodeDoc.code}.`);
|
||||
const { newUser, newSettings: _ } = await AddNewUser(txn, username, password, email);
|
||||
|
||||
if (isBootstrapInvite) {
|
||||
await txn
|
||||
.updateTable("account")
|
||||
.set({ auth_level: "admin" })
|
||||
.where("account.id", "=", newUser.id)
|
||||
.execute();
|
||||
|
||||
log.info(
|
||||
`Bootstrap invite consumed — user ${newUser.username} (${newUser.id}) is now admin.`,
|
||||
);
|
||||
} else if (ServerConfig.INVITE_CODE_CONFIG) {
|
||||
log.info(`Consumed invite ${inviteCode}.`);
|
||||
|
||||
await txn
|
||||
.updateTable("priv_invite")
|
||||
@@ -77,7 +131,7 @@ export const ANON_ACTION_Register = MakeAnonAction(
|
||||
consumed_at: new Date().toISOString(),
|
||||
consumed_by: newUser.id,
|
||||
})
|
||||
.where("code", "=", inviteCode)
|
||||
.where("priv_invite.code", "=", inviteCode!)
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { loadServerEnvFile } from "#lib/setup/load-server-env";
|
||||
loadServerEnvFile(process.env.NODE_ENV === "test" ? ".env.test" : ".env");
|
||||
|
||||
@@ -5,8 +6,12 @@ import { runCronTickOnce } from "#lib/jobs/cron/cron-service";
|
||||
import { log } from "#lib/log/log";
|
||||
import { Env } from "#lib/setup/config";
|
||||
import { ClosePgConnection } from "#services/pg/db";
|
||||
import { Sleep } from "#utils/misc";
|
||||
import { writeFileSync } from "fs";
|
||||
import { applyMigrations } from "tachi-db-migration-engine";
|
||||
|
||||
const HEARTBEAT_FILE = "/tmp/worker-heartbeat";
|
||||
|
||||
const TICK_MS = 5_000;
|
||||
|
||||
process.on("uncaughtException", (err, origin) => {
|
||||
@@ -23,6 +28,7 @@ void bootstrap();
|
||||
async function bootstrap() {
|
||||
await applyMigrations(Env.POSTGRES_URL, Env.MIGRATIONS_DIR);
|
||||
log.info({ bootInfo: true }, "tachi cron worker starting.");
|
||||
|
||||
let stopping = false;
|
||||
const shutdown = () => {
|
||||
stopping = true;
|
||||
@@ -32,8 +38,8 @@ async function bootstrap() {
|
||||
|
||||
// eslint-disable-next-line no-unmodified-loop-condition
|
||||
while (!stopping) {
|
||||
writeFileSync(HEARTBEAT_FILE, Date.now().toString());
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await runCronTickOnce();
|
||||
} catch (e) {
|
||||
log.error(e, "Cron tick error.");
|
||||
@@ -41,10 +47,7 @@ async function bootstrap() {
|
||||
if (stopping) {
|
||||
break;
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, TICK_MS);
|
||||
});
|
||||
await Sleep(TICK_MS);
|
||||
}
|
||||
log.info("Cron worker stopped.");
|
||||
await ClosePgConnection();
|
||||
|
||||
@@ -150,7 +150,7 @@ describe("DDR profileCalcs (flareSkill, Postgres)", () => {
|
||||
|
||||
it("counts at most 30 PBs per flare category (global flare order, per-category index)", async () => {
|
||||
const { id: userId } = await seedUser();
|
||||
// 31 CLASSIC rows, flareSkill 1031..1001 — sorted desc; index 30 is dropped (top < 30 keeps 0..29).
|
||||
// 31 CLASSIC rows, flareSkill 1031..1001 - sorted desc; index 30 is dropped (top < 30 keeps 0..29).
|
||||
for (let i = 0; i < 31; i++) {
|
||||
await seedDdrPbRow(userId, {
|
||||
flareSkill: 1031 - i,
|
||||
|
||||
@@ -236,7 +236,7 @@ describe("IIDX_IMPL (unit)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreCalcs ktLampRatingNC / ktLampRatingHC / ktLampRatingEXHC", () => {
|
||||
describe("scoreCalcs ktLampRatingHC / ktLampRatingEXHC", () => {
|
||||
it("IIDX SP", () => {
|
||||
const run = (
|
||||
scoreData: Partial<ScoreData<"iidx-sp">>,
|
||||
@@ -258,37 +258,30 @@ describe("IIDX_IMPL (unit)", () => {
|
||||
const tiered = { ncTier: mkTier(15), hcTier: mkTier(16), exhcTier: mkTier(17) };
|
||||
|
||||
expect(run({ lamp: "FAILED" }, {})).toMatchObject({
|
||||
ktLampRatingNC: 0,
|
||||
ktLampRatingHC: 0,
|
||||
ktLampRatingEXHC: 0,
|
||||
});
|
||||
expect(run({ lamp: "EASY CLEAR" }, {})).toMatchObject({
|
||||
ktLampRatingNC: 0,
|
||||
ktLampRatingHC: 0,
|
||||
ktLampRatingEXHC: 0,
|
||||
});
|
||||
expect(run({ lamp: "CLEAR" }, tiered)).toMatchObject({
|
||||
ktLampRatingNC: 15,
|
||||
ktLampRatingHC: 0,
|
||||
ktLampRatingEXHC: 0,
|
||||
});
|
||||
expect(run({ lamp: "HARD CLEAR" }, tiered)).toMatchObject({
|
||||
ktLampRatingNC: 15,
|
||||
ktLampRatingHC: 16,
|
||||
ktLampRatingEXHC: 0,
|
||||
});
|
||||
expect(run({ lamp: "EX HARD CLEAR" }, tiered)).toMatchObject({
|
||||
ktLampRatingNC: 15,
|
||||
ktLampRatingHC: 16,
|
||||
ktLampRatingEXHC: 17,
|
||||
});
|
||||
expect(run({ lamp: "FULL COMBO" }, tiered)).toMatchObject({
|
||||
ktLampRatingNC: 15,
|
||||
ktLampRatingHC: 16,
|
||||
ktLampRatingEXHC: 17,
|
||||
});
|
||||
expect(run({ lamp: "HARD CLEAR" }, { ncTier: mkTier(15) })).toMatchObject({
|
||||
ktLampRatingNC: 15,
|
||||
ktLampRatingHC: 15,
|
||||
ktLampRatingEXHC: 0,
|
||||
});
|
||||
@@ -313,27 +306,22 @@ describe("IIDX_IMPL (unit)", () => {
|
||||
}
|
||||
|
||||
expect(run({ lamp: "EASY CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({
|
||||
ktLampRatingNC: 0,
|
||||
ktLampRatingHC: 0,
|
||||
ktLampRatingEXHC: 0,
|
||||
});
|
||||
expect(run({ lamp: "CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({
|
||||
ktLampRatingNC: 15,
|
||||
ktLampRatingHC: 0,
|
||||
ktLampRatingEXHC: 0,
|
||||
});
|
||||
expect(run({ lamp: "HARD CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({
|
||||
ktLampRatingNC: 15,
|
||||
ktLampRatingHC: 15,
|
||||
ktLampRatingEXHC: 0,
|
||||
});
|
||||
expect(run({ lamp: "EX HARD CLEAR" }, { dpTier: mkTier(15) })).toMatchObject({
|
||||
ktLampRatingNC: 15,
|
||||
ktLampRatingHC: 15,
|
||||
ktLampRatingEXHC: 15,
|
||||
});
|
||||
expect(run({ lamp: "FULL COMBO" }, { dpTier: mkTier(15) })).toMatchObject({
|
||||
ktLampRatingNC: 15,
|
||||
ktLampRatingHC: 15,
|
||||
ktLampRatingEXHC: 15,
|
||||
});
|
||||
|
||||
@@ -29,23 +29,19 @@ type IIDXGames = "iidx-dp" | "iidx-sp";
|
||||
const IIDX_SESSION_CALCS: GPTSessionCalcs<IIDXGames> = (arr) => ({
|
||||
BPI: SessionAvgBest10For("BPI")(arr),
|
||||
ktLampRating: SessionAvgBest10For("ktLampRating")(arr),
|
||||
ktLampRatingNC: SessionAvgBest10For("ktLampRatingNC")(arr),
|
||||
ktLampRatingHC: SessionAvgBest10For("ktLampRatingHC")(arr),
|
||||
ktLampRatingEXHC: SessionAvgBest10For("ktLampRatingEXHC")(arr),
|
||||
});
|
||||
|
||||
const IIDX_PROFILE_CALCS: GPTProfileCalcs<IIDXGames> = async (game, userID) => {
|
||||
const [BPI, ktLampRating, ktLampRatingNC, ktLampRatingHC, ktLampRatingEXHC] = await Promise.all(
|
||||
[
|
||||
ProfileAvgBestN("BPI", 20, true)(game, userID),
|
||||
ProfileAvgBestN("ktLampRating", 20)(game, userID),
|
||||
ProfileAvgBestN("ktLampRatingNC", 20)(game, userID),
|
||||
ProfileAvgBestN("ktLampRatingHC", 20)(game, userID),
|
||||
ProfileAvgBestN("ktLampRatingEXHC", 20)(game, userID),
|
||||
],
|
||||
);
|
||||
const [BPI, ktLampRating, ktLampRatingHC, ktLampRatingEXHC] = await Promise.all([
|
||||
ProfileAvgBestN("BPI", 20, true)(game, userID),
|
||||
ProfileAvgBestN("ktLampRating", 20)(game, userID),
|
||||
ProfileAvgBestN("ktLampRatingHC", 20)(game, userID),
|
||||
ProfileAvgBestN("ktLampRatingEXHC", 20)(game, userID),
|
||||
]);
|
||||
|
||||
return { BPI, ktLampRating, ktLampRatingNC, ktLampRatingHC, ktLampRatingEXHC };
|
||||
return { BPI, ktLampRating, ktLampRatingHC, ktLampRatingEXHC };
|
||||
};
|
||||
|
||||
const IIDX_MERGERS: Array<PBMergeFunction<IIDXGames>> = [
|
||||
@@ -156,11 +152,6 @@ export const IIDX_SP_IMPL: GameImplementation<"iidx-sp"> = {
|
||||
ktLampRating = 0;
|
||||
}
|
||||
|
||||
const atLeastNcClear =
|
||||
scoreData.lamp === "CLEAR" ||
|
||||
scoreData.lamp === "HARD CLEAR" ||
|
||||
scoreData.lamp === "EX HARD CLEAR" ||
|
||||
scoreData.lamp === "FULL COMBO";
|
||||
const atLeastHcClear =
|
||||
scoreData.lamp === "HARD CLEAR" ||
|
||||
scoreData.lamp === "EX HARD CLEAR" ||
|
||||
@@ -168,14 +159,12 @@ export const IIDX_SP_IMPL: GameImplementation<"iidx-sp"> = {
|
||||
const atLeastExhcClear =
|
||||
scoreData.lamp === "EX HARD CLEAR" || scoreData.lamp === "FULL COMBO";
|
||||
|
||||
const ktLampRatingNC = atLeastNcClear ? ncValue : 0;
|
||||
const ktLampRatingHC = atLeastHcClear ? hcValue : 0;
|
||||
const ktLampRatingEXHC = atLeastExhcClear ? exhcValue : 0;
|
||||
|
||||
return {
|
||||
BPI: bpi,
|
||||
ktLampRating,
|
||||
ktLampRatingNC,
|
||||
ktLampRatingHC,
|
||||
ktLampRatingEXHC,
|
||||
};
|
||||
@@ -235,11 +224,6 @@ export const IIDX_DP_IMPL: GameImplementation<"iidx-dp"> = {
|
||||
ktLampRating = 0;
|
||||
}
|
||||
|
||||
const atLeastNcClear =
|
||||
scoreData.lamp === "CLEAR" ||
|
||||
scoreData.lamp === "HARD CLEAR" ||
|
||||
scoreData.lamp === "EX HARD CLEAR" ||
|
||||
scoreData.lamp === "FULL COMBO";
|
||||
const atLeastHcClear =
|
||||
scoreData.lamp === "HARD CLEAR" ||
|
||||
scoreData.lamp === "EX HARD CLEAR" ||
|
||||
@@ -247,14 +231,12 @@ export const IIDX_DP_IMPL: GameImplementation<"iidx-dp"> = {
|
||||
const atLeastExhcClear =
|
||||
scoreData.lamp === "EX HARD CLEAR" || scoreData.lamp === "FULL COMBO";
|
||||
|
||||
const ktLampRatingNC = atLeastNcClear ? ecValue : 0;
|
||||
const ktLampRatingHC = atLeastHcClear ? ecValue : 0;
|
||||
const ktLampRatingEXHC = atLeastExhcClear ? ecValue : 0;
|
||||
|
||||
return {
|
||||
BPI: bpi,
|
||||
ktLampRating,
|
||||
ktLampRatingNC,
|
||||
ktLampRatingHC,
|
||||
ktLampRatingEXHC,
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ import { GoalFmtPercent, GradeGoalFormatter } from "./_common";
|
||||
export const ITG_STAMINA_IMPL: GameImplementation<"itg-stamina"> = {
|
||||
chartSpecificValidators: {},
|
||||
scoreDeriver: (scoreData, _chart) => ({
|
||||
// *important*: don't check survivedPercent === 100 — floating point can
|
||||
// *important*: don't check survivedPercent === 100 - floating point can
|
||||
// produce a 100% survived-percent on a fail for very long charts.
|
||||
finalPercent:
|
||||
scoreData.lamp === "FAILED" ? scoreData.survivedPercent : 100 + scoreData.scorePercent,
|
||||
|
||||
@@ -60,7 +60,6 @@ describe("CreatePBMergeFor (Postgres)", () => {
|
||||
judgements: s.mongo.judgements ?? {},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await DB.insertInto("score")
|
||||
.values({
|
||||
id: scoreId,
|
||||
|
||||
@@ -10,8 +10,12 @@ import { processScoreImportJobFromPayload } from "#lib/score-import/worker/score
|
||||
import { Env } from "#lib/setup/config";
|
||||
import { ClosePgConnection } from "#services/pg/db";
|
||||
import { CloseRedisConnection } from "#services/redis/redis";
|
||||
import { Sleep } from "#utils/misc";
|
||||
import { writeFileSync } from "fs";
|
||||
import { applyMigrations } from "tachi-db-migration-engine";
|
||||
|
||||
const HEARTBEAT_FILE = "/tmp/worker-heartbeat";
|
||||
|
||||
const POLL_MS = 250;
|
||||
|
||||
process.on("uncaughtException", (err, origin) => {
|
||||
@@ -21,31 +25,32 @@ process.on("uncaughtException", (err, origin) => {
|
||||
|
||||
void bootstrap();
|
||||
|
||||
/**
|
||||
* Often run by `just server` (one or more via `TACHI_SERVER_JOB_WORKER_COUNT`); each process claims
|
||||
* with `FOR UPDATE SKIP LOCKED`.
|
||||
*/
|
||||
async function bootstrap() {
|
||||
await applyMigrations(Env.POSTGRES_URL, Env.MIGRATIONS_DIR);
|
||||
log.info({ bootInfo: true }, "tachi job-queue worker starting (Postgres job_queue).");
|
||||
|
||||
let stopping = false;
|
||||
const shutdown = () => {
|
||||
stopping = true;
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
// eslint-disable-next-line no-unmodified-loop-condition
|
||||
while (!stopping) {
|
||||
writeFileSync(HEARTBEAT_FILE, Date.now().toString());
|
||||
|
||||
const job = await ClaimNextJob();
|
||||
|
||||
if (!job) {
|
||||
if (stopping) {
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, POLL_MS);
|
||||
});
|
||||
|
||||
await Sleep(POLL_MS);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (job.job_kind) {
|
||||
case JOB_KIND_SCORE_IMPORT:
|
||||
@@ -61,6 +66,7 @@ async function bootstrap() {
|
||||
await MarkJobFailed(job.row_id);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Job worker loop stopped, closing resources.");
|
||||
await CloseScoreImportQueue();
|
||||
await CloseRedisConnection();
|
||||
|
||||
@@ -38,7 +38,7 @@ export type ActivityConstraint = {
|
||||
userID?: integer | { $in: Array<integer> };
|
||||
};
|
||||
|
||||
/** Kysely dynamic column refs — same pattern as `whereUserIdOnGoalSub` in `#utils/db`. */
|
||||
/** Kysely dynamic column refs - same pattern as `whereUserIdOnGoalSub` in `#utils/db`. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- OperandExpressionFactory varies by query (session / join / class_achievement).
|
||||
type ActivityWhereEb = any;
|
||||
|
||||
|
||||
@@ -92,13 +92,13 @@ export const SELECT_PB_DOCUMENT_WITH_LEADERBOARD = [
|
||||
export async function ToPbScoreDocument(row: PbDocumentJoinRow): Promise<PBScoreDocument> {
|
||||
const composedRows = await DB.selectFrom("pb_composed_from")
|
||||
.where("pb_id", "=", row.row_id)
|
||||
.select("score_id")
|
||||
.select(["score_id", "merge_name"])
|
||||
.execute();
|
||||
|
||||
const composedFrom: [PBReference, ...PBReference[]] =
|
||||
composedRows.length > 0
|
||||
? (composedRows.map((c, i) => ({
|
||||
name: i === 0 ? "Primary" : `Merge${i}`,
|
||||
? (composedRows.map((c) => ({
|
||||
name: c.merge_name,
|
||||
scoreID: c.score_id,
|
||||
})) as [PBReference, ...PBReference[]])
|
||||
: [{ name: "Primary", scoreID: "unknown" }];
|
||||
|
||||
@@ -23,7 +23,7 @@ try {
|
||||
} else {
|
||||
transporter.verify((err) => {
|
||||
if (err) {
|
||||
// Do NOT throw here — this is an async callback and the throw would
|
||||
// Do NOT throw here - this is an async callback and the throw would
|
||||
// become an uncaught exception, crashing the process. Log and exit instead.
|
||||
log.fatal({ err }, `Could not connect to email server.`);
|
||||
process.exit(1);
|
||||
@@ -65,7 +65,7 @@ export function SendEmail(
|
||||
headers: transporter.options.headers,
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
log.info(
|
||||
log.error(
|
||||
{
|
||||
err,
|
||||
subject,
|
||||
@@ -74,5 +74,6 @@ export function SendEmail(
|
||||
},
|
||||
`Failed to send email to ${to}.`,
|
||||
);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type Kysely, sql } from "kysely";
|
||||
|
||||
/**
|
||||
* Folder membership SQL (see tests in `folders.test.ts`).
|
||||
* Does not import the global `DB` module — safe for scripts that only set `POSTGRES_URL`.
|
||||
* Does not import the global `DB` module - safe for scripts that only set `POSTGRES_URL`.
|
||||
*/
|
||||
export async function BuildFolderQuery(folderID: string, db: Kysely<Database>) {
|
||||
const folder = await db
|
||||
@@ -75,7 +75,7 @@ export async function GetFolderChartIDs(folderID: string, db: Kysely<Database>)
|
||||
|
||||
/**
|
||||
* Evaluates `folder.where` SQL (same as a full rebuild). Used by
|
||||
* {@link rebuildFolderChartLookup}; do not call for normal reads — use {@link GetFolderChartIDs}.
|
||||
* {@link rebuildFolderChartLookup}; do not call for normal reads - use {@link GetFolderChartIDs}.
|
||||
*/
|
||||
export async function computeFolderChartIdsFromFolderSql(folderID: string, db: Kysely<Database>) {
|
||||
const { folderQuery } = await BuildFolderQuery(folderID, db);
|
||||
|
||||
@@ -46,7 +46,7 @@ function buildList(): Array<CronTaskDef> {
|
||||
},
|
||||
{
|
||||
id: "drain_stats_queues",
|
||||
schedule: "* * * * *",
|
||||
schedule: "* * * * *", // TODO(zk): really? this is so lazy
|
||||
description:
|
||||
"Drain score_rederive, pb_dirty, session_dirty, game_profile_dirty (ordered)",
|
||||
run: drainStatsQueuesInOrder,
|
||||
|
||||
@@ -291,7 +291,7 @@ export async function drainStatsQueuesInOrder(): Promise<void> {
|
||||
/**
|
||||
* Drain `score_rederive`, then `pb_dirty`, `session_dirty`, and `game_profile_dirty`,
|
||||
* repeating until a full pass moves nothing. No per-tick row cap (unlike the cron
|
||||
* drain) — intended for admin synchronous recalc.
|
||||
* drain) - intended for admin synchronous recalc.
|
||||
*/
|
||||
export async function drainStatsQueuesFully(): Promise<void> {
|
||||
for (;;) {
|
||||
@@ -345,7 +345,7 @@ export async function drainStatsQueuesFully(): Promise<void> {
|
||||
|
||||
/**
|
||||
* Drain `pb_dirty` then `session_dirty` and `game_profile_dirty`, repeating until
|
||||
* idle. No per-tick row cap — intended for admin synchronous PB recalc.
|
||||
* idle. No per-tick row cap - intended for admin synchronous PB recalc.
|
||||
*/
|
||||
export async function drainPbDirtyAndDownstream(): Promise<void> {
|
||||
for (;;) {
|
||||
|
||||
@@ -192,7 +192,7 @@ export class TypedRouter<S extends AnyRouterSpec> {
|
||||
if (!outputParseResult.success) {
|
||||
log.error(
|
||||
{ route, issues: outputParseResult.error.issues },
|
||||
"TypedRouter: output validation failed — handler returned body not matching spec output schema",
|
||||
"TypedRouter: output validation failed - handler returned body not matching spec output schema",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ export async function deleteOrphanScoreForUser(
|
||||
|
||||
/**
|
||||
* Lists orphan_score rows for a user, newest first, with keyset pagination on `row_id` + `time_inserted`.
|
||||
* @param afterRowID — `row_id` of the last item from the previous page (omit on first page).
|
||||
* @param afterRowID - `row_id` of the last item from the previous page (omit on first page).
|
||||
*/
|
||||
export async function listOrphanScoresForUser(opts: {
|
||||
afterRowID?: string;
|
||||
|
||||
@@ -103,6 +103,7 @@ export async function upsertPbFromMongoDoc(
|
||||
pbDoc.composedFrom.map((ref) => ({
|
||||
pb_id: pbId,
|
||||
score_id: ref.scoreID,
|
||||
merge_name: ref.name,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
|
||||
@@ -99,6 +99,7 @@ export async function finalizeImportToPostgres(
|
||||
await db
|
||||
.insertInto("import_game")
|
||||
.values(games.map((game) => ({ id: importID, game })))
|
||||
.onConflict((oc) => oc.columns(["id", "game"]).doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -113,6 +114,7 @@ export async function finalizeImportToPostgres(
|
||||
message: err.message,
|
||||
})),
|
||||
)
|
||||
.onConflict((oc) => oc.column("row_id").doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -130,6 +132,7 @@ export async function finalizeImportToPostgres(
|
||||
new: d.new,
|
||||
})),
|
||||
)
|
||||
.onConflict((oc) => oc.column("row_id").doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -145,6 +148,7 @@ export async function finalizeImportToPostgres(
|
||||
type: s.type.toLowerCase() as "appended" | "created",
|
||||
})),
|
||||
)
|
||||
.onConflict((oc) => oc.columns(["import_id", "session_id"]).doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -169,6 +173,7 @@ export async function finalizeImportToPostgres(
|
||||
new_progress_human: g.new.progressHuman,
|
||||
})),
|
||||
)
|
||||
.onConflict((oc) => oc.column("row_id").doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -187,6 +192,7 @@ export async function finalizeImportToPostgres(
|
||||
new_progress: q.new.progress,
|
||||
})),
|
||||
)
|
||||
.onConflict((oc) => oc.column("row_id").doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -211,23 +217,6 @@ export async function finalizeImportToPostgres(
|
||||
quest_secs: timing.questMs,
|
||||
total_secs: timing.totalMs,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.column("id").doUpdateSet({
|
||||
timestamp: tsNow,
|
||||
import_secs_avg: timing.importMs / n,
|
||||
import_parse_secs_avg: timing.importParseMs / n,
|
||||
pb_secs_avg: timing.pbMs / n,
|
||||
session_secs_avg: timing.sessionMs / n,
|
||||
parse_secs: timing.parseMs,
|
||||
import_secs: timing.importMs,
|
||||
import_parse_secs: timing.importParseMs,
|
||||
session_secs: timing.sessionMs,
|
||||
pb_secs: timing.pbMs,
|
||||
ugs_secs: timing.ugsMs,
|
||||
goal_secs: timing.goalMs,
|
||||
quest_secs: timing.questMs,
|
||||
total_secs: timing.totalMs,
|
||||
}),
|
||||
)
|
||||
.onConflict((oc) => oc.column("id").doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -2,8 +2,14 @@ import { seedUser } from "#actions/test-utils/api-tokens";
|
||||
import { CDNRetrieve } from "#lib/cdn/cdn";
|
||||
import { LoadImportDocumentById } from "#lib/db-formats/import-document";
|
||||
import DB from "#services/pg/db";
|
||||
import { FakeSmallBatchManual, Testing511Song, Testing511SPA } from "#test-utils/test-data";
|
||||
import {
|
||||
FakeSmallBatchManual,
|
||||
Testing511Song,
|
||||
Testing511SPA,
|
||||
TestingJubeatSong,
|
||||
} from "#test-utils/test-data";
|
||||
import { Sleep } from "#utils/misc";
|
||||
import { UnixMillisecondsToISO8601 } from "#utils/time";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { MakeScoreImport } from "./score-import";
|
||||
@@ -110,3 +116,273 @@ describe("MakeScoreImport (ported from score-import.oldtest.ts)", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const JUBEAT_ARRAY_IG_FIRST = 80000037;
|
||||
|
||||
function mkBatchManualMulterFile(body: object): Express.Multer.File {
|
||||
return {
|
||||
buffer: Buffer.from(JSON.stringify(body), "utf-8"),
|
||||
} as Express.Multer.File;
|
||||
}
|
||||
|
||||
function jubeatScoreLine(opts: {
|
||||
identifier: number | string;
|
||||
musicRate?: number;
|
||||
score?: number;
|
||||
timeAchieved: number;
|
||||
}) {
|
||||
return {
|
||||
difficulty: "ADV" as const,
|
||||
identifier: String(opts.identifier),
|
||||
judgements: {
|
||||
good: 0,
|
||||
great: 0,
|
||||
miss: 0,
|
||||
perfect: 100,
|
||||
poor: 0,
|
||||
},
|
||||
lamp: "CLEAR" as const,
|
||||
matchType: "inGameID" as const,
|
||||
musicRate: opts.musicRate ?? 96.5,
|
||||
score: opts.score ?? 920_000,
|
||||
timeAchieved: opts.timeAchieved,
|
||||
};
|
||||
}
|
||||
|
||||
async function seedJubeatChartArrayInGameID(opts: { chartId: string; legacyId: number }) {
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: `${TestingJubeatSong.id}-smoke-${opts.chartId}`,
|
||||
legacy_id: opts.legacyId,
|
||||
game_group: "jubeat",
|
||||
title: TestingJubeatSong.title,
|
||||
artist: TestingJubeatSong.artist,
|
||||
search_terms: TestingJubeatSong.searchTerms,
|
||||
alt_titles: TestingJubeatSong.altTitles,
|
||||
data: TestingJubeatSong.data,
|
||||
fts_document: "",
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("chart")
|
||||
.values({
|
||||
id: opts.chartId,
|
||||
legacy_id: String(opts.legacyId),
|
||||
game: "jubeat",
|
||||
song_id: `${TestingJubeatSong.id}-smoke-${opts.chartId}`,
|
||||
difficulty: "ADV",
|
||||
level: "6",
|
||||
level_num: 6,
|
||||
is_primary: true,
|
||||
versions: ["festo"],
|
||||
data: JSON.stringify({
|
||||
inGameID: [JUBEAT_ARRAY_IG_FIRST, 50_000_020],
|
||||
noteCount: 100,
|
||||
musicBar: [0, 1, 2, 3],
|
||||
}),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function seedJubeatChartScalarInGameID(opts: {
|
||||
chartId: string;
|
||||
inGameID: number;
|
||||
legacyId: number;
|
||||
songIdSuffix: string;
|
||||
}) {
|
||||
await DB.insertInto("song")
|
||||
.values({
|
||||
id: `${TestingJubeatSong.id}-${opts.songIdSuffix}`,
|
||||
legacy_id: opts.legacyId,
|
||||
game_group: "jubeat",
|
||||
title: TestingJubeatSong.title,
|
||||
artist: TestingJubeatSong.artist,
|
||||
search_terms: TestingJubeatSong.searchTerms,
|
||||
alt_titles: TestingJubeatSong.altTitles,
|
||||
data: TestingJubeatSong.data,
|
||||
fts_document: "",
|
||||
})
|
||||
.execute();
|
||||
|
||||
await DB.insertInto("chart")
|
||||
.values({
|
||||
id: opts.chartId,
|
||||
legacy_id: String(opts.legacyId),
|
||||
game: "jubeat",
|
||||
song_id: `${TestingJubeatSong.id}-${opts.songIdSuffix}`,
|
||||
difficulty: "ADV",
|
||||
level: "6",
|
||||
level_num: 6,
|
||||
is_primary: true,
|
||||
versions: ["festo"],
|
||||
data: JSON.stringify({
|
||||
inGameID: opts.inGameID,
|
||||
noteCount: 100,
|
||||
musicBar: [0, 1, 2, 3],
|
||||
}),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe("batch-manual score import (smoke)", () => {
|
||||
beforeEach(async () => {
|
||||
await seedUser({
|
||||
username: "test_batch_manual_smoke",
|
||||
email: "batch-manual-smoke@example.com",
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("file/batch-manual: jubeat inGameID matches chart with array inGameID and commits scores", async () => {
|
||||
const chartId = "chart-smoke-jubeat-array-ingameid";
|
||||
await seedJubeatChartArrayInGameID({ chartId, legacyId: 9_001 });
|
||||
|
||||
const baseMs = Date.UTC(2024, 5, 1, 12, 0, 0, 0);
|
||||
const batch = {
|
||||
meta: { game: "jubeat", playtype: "Single", service: "smoke-test" },
|
||||
scores: [jubeatScoreLine({ identifier: JUBEAT_ARRAY_IG_FIRST, timeAchieved: baseMs })],
|
||||
};
|
||||
|
||||
const importID = "import-smoke-jubeat-array";
|
||||
const doc = await MakeScoreImport({
|
||||
importID,
|
||||
importType: "file/batch-manual",
|
||||
parserArguments: [mkBatchManualMulterFile(batch), {}],
|
||||
userID: 1,
|
||||
userIntent: true,
|
||||
});
|
||||
|
||||
expect(doc.scoreIDs).toHaveLength(1);
|
||||
expect(doc.errors).toHaveLength(0);
|
||||
|
||||
const nCommitted = await DB.selectFrom("score")
|
||||
.select((eb) => eb.fn.countAll<number>().as("c"))
|
||||
.where("import_id", "=", importID)
|
||||
.where("committed", "=", true)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(Number(nCommitted?.c)).toBe(1);
|
||||
});
|
||||
|
||||
it("second import of identical data reports no sessions", async () => {
|
||||
await seedJubeatChartScalarInGameID({
|
||||
chartId: "chart-dedup-sessions",
|
||||
inGameID: 20_000_001,
|
||||
legacyId: 9_010,
|
||||
songIdSuffix: "dedup-sessions-song",
|
||||
});
|
||||
|
||||
const baseMs = Date.UTC(2024, 3, 1, 14, 0, 0, 0);
|
||||
const batch = {
|
||||
meta: { game: "jubeat", playtype: "Single", service: "dedup-test" },
|
||||
scores: [jubeatScoreLine({ identifier: 20_000_001, timeAchieved: baseMs })],
|
||||
};
|
||||
|
||||
const doc1 = await MakeScoreImport({
|
||||
importID: "import-dedup-sessions-1",
|
||||
importType: "file/batch-manual",
|
||||
parserArguments: [mkBatchManualMulterFile(batch), {}],
|
||||
userID: 1,
|
||||
userIntent: true,
|
||||
});
|
||||
|
||||
// Sanity check: first import should have created exactly one session
|
||||
expect(doc1.createdSessions).toHaveLength(1);
|
||||
expect(doc1.createdSessions[0]?.type).toBe("Created");
|
||||
|
||||
const doc2 = await MakeScoreImport({
|
||||
importID: "import-dedup-sessions-2",
|
||||
importType: "file/batch-manual",
|
||||
parserArguments: [mkBatchManualMulterFile(batch), {}],
|
||||
userID: 1,
|
||||
userIntent: true,
|
||||
});
|
||||
|
||||
// Second import of identical data should claim no sessions — no new scores
|
||||
// were actually committed, so no session was touched.
|
||||
expect(doc2.createdSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("file/batch-manual: two session groups appending the same nearby session finalizes import_session without error", async () => {
|
||||
await seedJubeatChartScalarInGameID({
|
||||
chartId: "chart-smoke-jubeat-dup-a",
|
||||
inGameID: 10_000_001,
|
||||
legacyId: 9_002,
|
||||
songIdSuffix: "smoke2-song-a",
|
||||
});
|
||||
await seedJubeatChartScalarInGameID({
|
||||
chartId: "chart-smoke-jubeat-dup-b",
|
||||
inGameID: 10_000_002,
|
||||
legacyId: 9_003,
|
||||
songIdSuffix: "smoke2-song-b",
|
||||
});
|
||||
|
||||
const baseMs = Date.UTC(2024, 5, 10, 8, 0, 0, 0);
|
||||
const sessionId = `Q${"a".repeat(40)}`;
|
||||
|
||||
const sixHoursMs = 6 * 60 * 60 * 1000;
|
||||
const oneHourMs = 60 * 60 * 1000;
|
||||
|
||||
await DB.insertInto("session")
|
||||
.values({
|
||||
id: sessionId,
|
||||
user_id: 1,
|
||||
game: "jubeat",
|
||||
name: "preseed-smoke",
|
||||
description: null,
|
||||
time_inserted: UnixMillisecondsToISO8601(Date.now()),
|
||||
time_started: UnixMillisecondsToISO8601(baseMs),
|
||||
time_ended: UnixMillisecondsToISO8601(baseMs + sixHoursMs),
|
||||
calculated_data: JSON.stringify({ jubility: null }),
|
||||
highlight: false,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const batch = {
|
||||
meta: { game: "jubeat", playtype: "Single", service: "smoke-test" },
|
||||
scores: [
|
||||
jubeatScoreLine({
|
||||
identifier: 10_000_001,
|
||||
musicRate: 95.0,
|
||||
score: 910_000,
|
||||
timeAchieved: baseMs + oneHourMs,
|
||||
}),
|
||||
jubeatScoreLine({
|
||||
identifier: 10_000_002,
|
||||
musicRate: 97.0,
|
||||
score: 925_000,
|
||||
timeAchieved: baseMs + 5 * oneHourMs,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const importID = "import-smoke-jubeat-session-meta";
|
||||
const doc = await MakeScoreImport({
|
||||
importID,
|
||||
importType: "file/batch-manual",
|
||||
parserArguments: [mkBatchManualMulterFile(batch), {}],
|
||||
userID: 1,
|
||||
userIntent: true,
|
||||
});
|
||||
|
||||
expect(doc.scoreIDs).toHaveLength(2);
|
||||
expect(doc.errors).toHaveLength(0);
|
||||
|
||||
const nCommitted = await DB.selectFrom("score")
|
||||
.select((eb) => eb.fn.countAll<number>().as("c"))
|
||||
.where("import_id", "=", importID)
|
||||
.where("committed", "=", true)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(Number(nCommitted?.c)).toBe(2);
|
||||
|
||||
const importSessionRows = await DB.selectFrom("import_session")
|
||||
.select("session_id")
|
||||
.where("import_id", "=", importID)
|
||||
.execute();
|
||||
|
||||
expect(importSessionRows).toHaveLength(1);
|
||||
expect(importSessionRows[0]?.session_id).toBe(sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import ScoreImportFatalError from "./score-importing/score-import-error";
|
||||
* Makes a score import given ScoreImportJobData (same process as the API).
|
||||
*
|
||||
* When `USE_EXTERNAL_SCORE_IMPORT_WORKER` is true, HTTP routes call
|
||||
* {@link EnqueueScoreImportJob} instead — this function is only used for the
|
||||
* {@link EnqueueScoreImportJob} instead - this function is only used for the
|
||||
* inline (non-queued) path.
|
||||
*/
|
||||
export async function MakeScoreImport<I extends ImportTypes>(
|
||||
|
||||
@@ -155,7 +155,7 @@ describe("CreateScoreID", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("is deterministic (canary — changing the algorithm is a breaking change)", () => {
|
||||
it("is deterministic (canary - changing the algorithm is a breaking change)", () => {
|
||||
const scoreID = CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.chartID);
|
||||
|
||||
expect(scoreID).toBe("T5d669c4d5d6ca80761e87698acd77c51d2bed95b64ab76e65952dbca7c26bc81");
|
||||
|
||||
+1
-1
@@ -303,7 +303,7 @@ export async function HandlePostImportSteps(
|
||||
// best score).
|
||||
const pbTimeStart = process.hrtime.bigint();
|
||||
|
||||
// processing PBs is a playtype-specific action. As such, we need to split chartIDs
|
||||
// processing PBs is a game-specific action. As such, we need to split chartIDs
|
||||
// accordingly
|
||||
const chartIDsSeparatedByGame: ChartIDGameMap = {};
|
||||
|
||||
|
||||
@@ -407,7 +407,7 @@ async function HydrateCheckAndInsertScore(
|
||||
}
|
||||
} else {
|
||||
if (importId === null && !directCommit) {
|
||||
log.debug(`Skipped score — missing import id.`);
|
||||
log.debug(`Skipped score - missing import id.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ const TWO_HOURS = ONE_HOUR * 2;
|
||||
export async function CreateSessions(userID: integer, scoreGameMap: ScoreGameMap, log: KtLogger) {
|
||||
const allSessionInfo = [];
|
||||
|
||||
console.dir(scoreGameMap);
|
||||
|
||||
for (const [game, scores] of Object.entries(scoreGameMap)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const sessionInfo = await LoadScoresIntoSessions(userID, scores, game as V3Game, log);
|
||||
@@ -36,6 +38,8 @@ export async function CreateSessions(userID: integer, scoreGameMap: ScoreGameMap
|
||||
allSessionInfo.push(...sessionInfo);
|
||||
}
|
||||
|
||||
console.dir(allSessionInfo);
|
||||
|
||||
return allSessionInfo;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,6 @@ describe("UpdateUsersGamePlaytypeStats (ported from update-ugpt-stats.oldtest.ts
|
||||
const ratings = typeof gp.ratings === "string" ? JSON.parse(gp.ratings) : gp.ratings;
|
||||
expect(ratings).toMatchObject({
|
||||
ktLampRating: expect.any(Number),
|
||||
ktLampRatingNC: expect.any(Number),
|
||||
ktLampRatingHC: expect.any(Number),
|
||||
ktLampRatingEXHC: expect.any(Number),
|
||||
});
|
||||
@@ -175,7 +174,6 @@ describe("UpdateUsersGamePlaytypeStats (ported from update-ugpt-stats.oldtest.ts
|
||||
calculatedData: {
|
||||
BPI: 10.1,
|
||||
ktLampRating: e,
|
||||
ktLampRatingNC: e,
|
||||
ktLampRatingHC: e,
|
||||
ktLampRatingEXHC: e,
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ async function* streamPlaylog(apiId: string, log: KtLogger): AsyncIterable<MytWa
|
||||
try {
|
||||
for await (const item of client.getPlaylog(request)) {
|
||||
if (!item.info) {
|
||||
log.warn(`Received WACCA playlog stream item with no info — skipping.`);
|
||||
log.warn(`Received WACCA playlog stream item with no info - skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@ describe("#ParserFn", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("No Playtype — game group without playtype is not a V3 game", () => {
|
||||
it("No Playtype - game group without playtype is not a V3 game", () => {
|
||||
expectThrowsFatal(
|
||||
() =>
|
||||
ParserFn(
|
||||
|
||||
@@ -47,7 +47,7 @@ async function finalizeHits(
|
||||
|
||||
/**
|
||||
* Search a user's sessions for one GPT: `websearch_to_tsquery` on `session.textsearch`, optional
|
||||
* short-query exact match, then pg_trgm / ILIKE — same strategy as
|
||||
* short-query exact match, then pg_trgm / ILIKE - same strategy as
|
||||
* {@link SearchSongsForGameFtsAndTrgm} / {@link SearchFoldersForGameFtsAndTrgm}.
|
||||
*
|
||||
* Returns full {@link SessionDocument}s (no follow-up `WHERE id IN (...)` on `session`).
|
||||
|
||||
@@ -31,23 +31,23 @@ describe("Postgres song search (full seeds)", () => {
|
||||
await importSeeds(DB, SEEDS_DIR);
|
||||
}, 600_000);
|
||||
|
||||
bench("iidx FTS — gradius (title)", async () => {
|
||||
bench("iidx FTS - gradius (title)", async () => {
|
||||
await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "gradius", 50);
|
||||
});
|
||||
|
||||
bench("iidx FTS — taka (artist)", async () => {
|
||||
bench("iidx FTS - taka (artist)", async () => {
|
||||
await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "taka", 50);
|
||||
});
|
||||
|
||||
bench("iidx short query — ab (FTS + trgm)", async () => {
|
||||
bench("iidx short query - ab (FTS + trgm)", async () => {
|
||||
await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "ab", 50);
|
||||
});
|
||||
|
||||
bench("iidx sparse — xyzunlikely (mostly trgm / empty FTS)", async () => {
|
||||
bench("iidx sparse - xyzunlikely (mostly trgm / empty FTS)", async () => {
|
||||
await SearchSongsForGameFtsAndTrgm(GAME_IIDX, "xyzunlikely", 50);
|
||||
});
|
||||
|
||||
bench("bms FTS — fezike (artist)", async () => {
|
||||
bench("bms FTS - fezike (artist)", async () => {
|
||||
await SearchSongsForGameFtsAndTrgm(GAME_BMS, "fezike", 50);
|
||||
});
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export type SongSearchRow = {
|
||||
|
||||
/**
|
||||
* Indexed song search: PostgreSQL FTS (tsvector) plus optional pg_trgm / ILIKE fallback
|
||||
* (Zenith-style — no full-table load, no huge IN lists).
|
||||
* (Zenith-style - no full-table load, no huge IN lists).
|
||||
*
|
||||
* Queries with length ≤ {@link SHORT_QUERY_STRICT_MAX_LEN} also run an **exact** match pass
|
||||
* (title, artist, `search_terms`, `alt_titles`) with a boosted rank; substring
|
||||
|
||||
@@ -7,10 +7,12 @@ describe("buildEmailConfig", () => {
|
||||
expect(() => buildEmailConfig({})).toThrow(/TACHI_EMAIL_FROM is required/u);
|
||||
});
|
||||
|
||||
it("builds Postmark transport when TACHI_EMAIL_AUTH_POSTMARK is true", () => {
|
||||
it("builds Postmark transport when host is smtp.postmarkapp.com", () => {
|
||||
const cfg = buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "from@example.com",
|
||||
TACHI_EMAIL_AUTH_POSTMARK: "true",
|
||||
TACHI_EMAIL_HOST: "smtp.postmarkapp.com",
|
||||
TACHI_EMAIL_PORT: "587",
|
||||
TACHI_EMAIL_SECURE: "false",
|
||||
TACHI_EMAIL_AUTH_PASS: "pm-token",
|
||||
});
|
||||
expect(cfg.TRANSPORT_OPS).toMatchObject({
|
||||
@@ -21,10 +23,11 @@ describe("buildEmailConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses TACHI_EMAIL_AUTH_USER when POSTMARK and only user is set", () => {
|
||||
it("uses TACHI_EMAIL_AUTH_USER for Postmark when only user is set", () => {
|
||||
const cfg = buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "from@example.com",
|
||||
TACHI_EMAIL_AUTH_POSTMARK: "1",
|
||||
TACHI_EMAIL_HOST: "smtp.postmarkapp.com",
|
||||
TACHI_EMAIL_PORT: "587",
|
||||
TACHI_EMAIL_AUTH_USER: "only-user-token",
|
||||
});
|
||||
expect(cfg.TRANSPORT_OPS).toMatchObject({
|
||||
@@ -32,19 +35,32 @@ describe("buildEmailConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when POSTMARK is true but no token is given", () => {
|
||||
it("matches Postmark host case-insensitively for auth token rules", () => {
|
||||
const cfg = buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "from@example.com",
|
||||
TACHI_EMAIL_HOST: "SMTP.POSTMARKAPP.COM",
|
||||
TACHI_EMAIL_PORT: "587",
|
||||
TACHI_EMAIL_AUTH_PASS: "pm-token",
|
||||
});
|
||||
expect(cfg.TRANSPORT_OPS).toMatchObject({
|
||||
host: "SMTP.POSTMARKAPP.COM",
|
||||
auth: { user: "pm-token", pass: "pm-token" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when Postmark host is set but no token is given", () => {
|
||||
expect(() =>
|
||||
buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "a@b.com",
|
||||
TACHI_EMAIL_AUTH_POSTMARK: "true",
|
||||
TACHI_EMAIL_HOST: "smtp.postmarkapp.com",
|
||||
TACHI_EMAIL_PORT: "587",
|
||||
}),
|
||||
).toThrow(/TACHI_EMAIL_AUTH_PASS or TACHI_EMAIL_AUTH_USER/u);
|
||||
});
|
||||
|
||||
it("builds generic SMTP without auth when POSTMARK is false", () => {
|
||||
it("builds generic SMTP without auth", () => {
|
||||
const cfg = buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "dev@localhost",
|
||||
TACHI_EMAIL_AUTH_POSTMARK: "false",
|
||||
TACHI_EMAIL_HOST: "tachi-mailpit",
|
||||
TACHI_EMAIL_PORT: "1025",
|
||||
TACHI_EMAIL_SECURE: "false",
|
||||
@@ -59,7 +75,6 @@ describe("buildEmailConfig", () => {
|
||||
it("includes auth when user and/or pass are set for generic SMTP", () => {
|
||||
const cfg = buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "a@b.com",
|
||||
TACHI_EMAIL_AUTH_POSTMARK: "false",
|
||||
TACHI_EMAIL_HOST: "smtp.example",
|
||||
TACHI_EMAIL_PORT: "587",
|
||||
TACHI_EMAIL_SECURE: "false",
|
||||
@@ -77,7 +92,6 @@ describe("buildEmailConfig", () => {
|
||||
it("defaults TACHI_EMAIL_SECURE to false", () => {
|
||||
const cfg = buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "a@b.com",
|
||||
TACHI_EMAIL_AUTH_POSTMARK: "false",
|
||||
TACHI_EMAIL_HOST: "h",
|
||||
TACHI_EMAIL_PORT: "25",
|
||||
});
|
||||
@@ -88,7 +102,6 @@ describe("buildEmailConfig", () => {
|
||||
expect(() =>
|
||||
buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "a@b.com",
|
||||
TACHI_EMAIL_AUTH_POSTMARK: "false",
|
||||
TACHI_EMAIL_PORT: "1025",
|
||||
}),
|
||||
).toThrow(/TACHI_EMAIL_HOST is required/u);
|
||||
@@ -98,7 +111,6 @@ describe("buildEmailConfig", () => {
|
||||
expect(() =>
|
||||
buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "a@b.com",
|
||||
TACHI_EMAIL_AUTH_POSTMARK: "false",
|
||||
TACHI_EMAIL_HOST: "localhost",
|
||||
}),
|
||||
).toThrow(/TACHI_EMAIL_PORT is required/u);
|
||||
@@ -108,7 +120,6 @@ describe("buildEmailConfig", () => {
|
||||
expect(() =>
|
||||
buildEmailConfig({
|
||||
TACHI_EMAIL_FROM: "a@b.com",
|
||||
TACHI_EMAIL_AUTH_POSTMARK: "false",
|
||||
TACHI_EMAIL_HOST: "localhost",
|
||||
TACHI_EMAIL_PORT: "nope",
|
||||
}),
|
||||
|
||||
@@ -82,6 +82,7 @@ const configSchema = z.object({
|
||||
BETA_USER_BONUS: z.number().int().nonnegative(),
|
||||
})
|
||||
.optional(),
|
||||
INVITE_ADMIN_INITIAL_INVITE_CODE: z.string().optional(),
|
||||
TACHI_CONFIG: z.object({
|
||||
NAME: z.string(),
|
||||
TYPE: z.enum(["kamai", "boku", "omni"]),
|
||||
@@ -263,14 +264,17 @@ function envBoolFrom(env: NodeJS.ProcessEnv, key: string, defaultVal: boolean):
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
const POSTMARK_SMTP_HOST = "smtp.postmarkapp.com";
|
||||
|
||||
/**
|
||||
* SMTP settings from env. Required for every deployment.
|
||||
*
|
||||
* - `TACHI_EMAIL_FROM` — `From` header (must match Postmark sender when using Postmark).
|
||||
* - `TACHI_EMAIL_AUTH_POSTMARK` — if `true`, uses Postmark SMTP; set `TACHI_EMAIL_AUTH_USER` /
|
||||
* `TACHI_EMAIL_AUTH_PASS` to your server token (both are the token for Postmark).
|
||||
* - Otherwise: `TACHI_EMAIL_HOST`, `TACHI_EMAIL_PORT`, `TACHI_EMAIL_SECURE`, and optionally
|
||||
* `TACHI_EMAIL_AUTH_USER` / `TACHI_EMAIL_AUTH_PASS` (e.g. Mailpit: no auth).
|
||||
* - `TACHI_EMAIL_FROM` - `From` header (must match a verified sender when using Postmark).
|
||||
* - `TACHI_EMAIL_HOST`, `TACHI_EMAIL_PORT`, `TACHI_EMAIL_SECURE` (`true` / `false`).
|
||||
* - Optionally `TACHI_EMAIL_AUTH_USER` / `TACHI_EMAIL_AUTH_PASS` when the server needs SMTP auth
|
||||
* (Mailpit locally typically needs none).
|
||||
* - For Postmark, set host to `smtp.postmarkapp.com` (commonly port `587`, `TACHI_EMAIL_SECURE=false`).
|
||||
* Either auth field may hold the server token; the other is filled with the same value.
|
||||
*
|
||||
* @internal Exported for unit tests.
|
||||
*/
|
||||
@@ -280,39 +284,14 @@ export function buildEmailConfig(env: NodeJS.ProcessEnv): TachiServerConfig["EMA
|
||||
throw new Error(`TACHI_EMAIL_FROM is required.`);
|
||||
}
|
||||
|
||||
if (envBoolFrom(env, "TACHI_EMAIL_AUTH_POSTMARK", false)) {
|
||||
const pass = envOptFrom(env, "TACHI_EMAIL_AUTH_PASS");
|
||||
const user = envOptFrom(env, "TACHI_EMAIL_AUTH_USER");
|
||||
const token = pass ?? user;
|
||||
if (token === undefined) {
|
||||
throw new Error(
|
||||
`TACHI_EMAIL_AUTH_PASS or TACHI_EMAIL_AUTH_USER is required when TACHI_EMAIL_AUTH_POSTMARK is true.`,
|
||||
);
|
||||
}
|
||||
const authUser = user ?? token;
|
||||
const authPass = pass ?? token;
|
||||
return {
|
||||
FROM: from,
|
||||
TRANSPORT_OPS: {
|
||||
host: "smtp.postmarkapp.com",
|
||||
port: 587,
|
||||
secure: false,
|
||||
auth: {
|
||||
user: authUser,
|
||||
pass: authPass,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const host = envOptFrom(env, "TACHI_EMAIL_HOST");
|
||||
if (host === undefined) {
|
||||
throw new Error(`TACHI_EMAIL_HOST is required when TACHI_EMAIL_AUTH_POSTMARK is false.`);
|
||||
throw new Error(`TACHI_EMAIL_HOST is required.`);
|
||||
}
|
||||
|
||||
const portRaw = envOptFrom(env, "TACHI_EMAIL_PORT");
|
||||
if (portRaw === undefined) {
|
||||
throw new Error(`TACHI_EMAIL_PORT is required when TACHI_EMAIL_AUTH_POSTMARK is false.`);
|
||||
throw new Error(`TACHI_EMAIL_PORT is required.`);
|
||||
}
|
||||
const port = Number.parseInt(portRaw, 10);
|
||||
if (Number.isNaN(port)) {
|
||||
@@ -320,8 +299,19 @@ export function buildEmailConfig(env: NodeJS.ProcessEnv): TachiServerConfig["EMA
|
||||
}
|
||||
|
||||
const secure = envBoolFrom(env, "TACHI_EMAIL_SECURE", false);
|
||||
const authUser = envOptFrom(env, "TACHI_EMAIL_AUTH_USER");
|
||||
const authPass = envOptFrom(env, "TACHI_EMAIL_AUTH_PASS");
|
||||
let authUser = envOptFrom(env, "TACHI_EMAIL_AUTH_USER");
|
||||
let authPass = envOptFrom(env, "TACHI_EMAIL_AUTH_PASS");
|
||||
|
||||
if (host.toLowerCase() === POSTMARK_SMTP_HOST) {
|
||||
const token = authPass ?? authUser;
|
||||
if (token === undefined) {
|
||||
throw new Error(
|
||||
`TACHI_EMAIL_AUTH_PASS or TACHI_EMAIL_AUTH_USER is required when TACHI_EMAIL_HOST is ${POSTMARK_SMTP_HOST}.`,
|
||||
);
|
||||
}
|
||||
authUser = authUser ?? token;
|
||||
authPass = authPass ?? token;
|
||||
}
|
||||
|
||||
const transportOps: Record<string, unknown> = {
|
||||
host,
|
||||
@@ -393,6 +383,7 @@ const eagOauth = oauth2Optional("EAG");
|
||||
const minOauth = oauth2Optional("MIN");
|
||||
const emailCfg = emailConfig();
|
||||
const inviteCfg = inviteCodeConfig();
|
||||
const bootstrapInvite = opt("TACHI_INVITE_ADMIN_INITIAL_INVITE_CODE")?.trim() || undefined;
|
||||
const seedsCfg = seedsConfig();
|
||||
const clientDev = clientDevServer();
|
||||
const extWorkerConc = opt("TACHI_EXTERNAL_SCORE_IMPORT_WORKER_CONCURRENCY");
|
||||
@@ -437,6 +428,7 @@ const configFromEnv: unknown = {
|
||||
MAX_RIVALS: parseIntEnv("TACHI_MAX_RIVALS", 5),
|
||||
OUR_URL: req("TACHI_OUR_URL"),
|
||||
...(inviteCfg !== undefined ? { INVITE_CODE_CONFIG: inviteCfg } : {}),
|
||||
...(bootstrapInvite !== undefined ? { INVITE_ADMIN_INITIAL_INVITE_CODE: bootstrapInvite } : {}),
|
||||
TACHI_CONFIG: {
|
||||
NAME: req("TACHI_NAME"),
|
||||
TYPE: req("TACHI_TYPE"),
|
||||
@@ -526,14 +518,14 @@ if (!MIGRATIONS_DIR) {
|
||||
let version = process.env.VERSION;
|
||||
|
||||
if (!version) {
|
||||
log.error(`No VERSION specified in environment. defaulting to 0.0.0.`);
|
||||
log.warn(`No VERSION specified in environment. defaulting to 0.0.0.`);
|
||||
version = "0.0.0";
|
||||
}
|
||||
|
||||
let commitHash = process.env.COMMIT_HASH;
|
||||
|
||||
if (!commitHash) {
|
||||
log.error(`No COMMIT_HASH specified in environment. defaulting to unknown commit.`);
|
||||
log.warn(`No COMMIT_HASH specified in environment. defaulting to unknown commit.`);
|
||||
commitHash = "unknown";
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Stress **`POST /api/v1/import/file`** against a **running** Tachi instance using
|
||||
|
||||
- Server reachable (e.g. `http://127.0.0.1:8080` when using local `PORT`).
|
||||
- Accounts with **`submit_score`**:
|
||||
- **Session:** login cookie (see below) — **one in-flight import per user**, so keep **`--concurrency 1`** unless you only care about rate limits.
|
||||
- **Session:** login cookie (see below) - **one in-flight import per user**, so keep **`--concurrency 1`** unless you only care about rate limits.
|
||||
- **API tokens:** one token per parallel slot. Generate many tokens with the seeder (below).
|
||||
|
||||
**Rate limiting:** In dev, score imports are limited to **5 per minute per IP** unless you set:
|
||||
@@ -96,5 +96,5 @@ just load-test-score-import -- \
|
||||
|
||||
## Files
|
||||
|
||||
- `score-import-load-cli.ts` — multipart client.
|
||||
- `seed-stress-api-tokens.ts` — bulk token seeder for dev DBs.
|
||||
- `score-import-load-cli.ts` - multipart client.
|
||||
- `seed-stress-api-tokens.ts` - bulk token seeder for dev DBs.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* HTTP stress harness: multipart POST /api/v1/import/file against a live Tachi instance.
|
||||
*
|
||||
* Uses real fixtures under src/test-utils/test-data/ (CSV, XML, JSON) — not MER.
|
||||
* Uses real fixtures under src/test-utils/test-data/ (CSV, XML, JSON) - not MER.
|
||||
*
|
||||
* One in-flight import per user (409 if you exceed). Use a token pool sized ≥ concurrency.
|
||||
*
|
||||
@@ -155,7 +155,7 @@ function maybeMutateFileBody(
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
// XML / others: suffix a harmless byte that most XML parsers ignore after root — skip
|
||||
// XML / others: suffix a harmless byte that most XML parsers ignore after root - skip
|
||||
return buf;
|
||||
}
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ function tsReq(ms: number): string {
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// API permission helpers
|
||||
// (Old MongoDB data uses dash-separated permission names, not the underscore
|
||||
// form in the current APIPermissions type — kept as plain string lookups.)
|
||||
// form in the current APIPermissions type - kept as plain string lookups.)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Extract one Postgres pm_* column from a MongoDB requestedPermissions array. */
|
||||
@@ -371,7 +371,7 @@ async function main(): Promise<void> {
|
||||
const chartIdMap = buildChartIdMap(SEEDS_DIR);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// LEVEL 0 — No FK dependencies
|
||||
// LEVEL 0 - No FK dependencies
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n── Level 0 ──────────────────────────────────────────────────────");
|
||||
@@ -489,7 +489,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// LEVEL 1 — Depend on account and/or level-0 tables
|
||||
// LEVEL 1 - Depend on account and/or level-0 tables
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n── Level 1 ──────────────────────────────────────────────────────");
|
||||
@@ -894,7 +894,7 @@ async function main(): Promise<void> {
|
||||
game: mongoGameToPg(a.game, (a as { playtype?: string }).playtype),
|
||||
user_id: a.userID,
|
||||
class_set: a.classSet as string,
|
||||
// classOldValue can be null in Mongo; Postgres requires a string — use empty string.
|
||||
// classOldValue can be null in Mongo; Postgres requires a string - use empty string.
|
||||
class_prev_value: a.classOldValue ?? "",
|
||||
class_value: a.classValue,
|
||||
timestamp: tsReq(a.timeAchieved),
|
||||
@@ -1049,18 +1049,18 @@ async function main(): Promise<void> {
|
||||
|
||||
if (skipped > 0) {
|
||||
console.warn(
|
||||
` [import_session] Skipped ${skipped} row(s) — session not found`,
|
||||
` [import_session] Skipped ${skipped} row(s) - session not found`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Base rows first — children have FK references to import(id).
|
||||
// Base rows first - children have FK references to import(id).
|
||||
await batchInsert("import", importRows);
|
||||
await batchInsert("import_game", importGameRows);
|
||||
await batchInsert("import_error", importErrorRows);
|
||||
await batchInsert("import_class", importClassRows);
|
||||
await batchInsert("import_session", importSessionRows);
|
||||
// import_goal and import_quest are intentionally skipped — the
|
||||
// import_goal and import_quest are intentionally skipped - the
|
||||
// historical data doesn't align with reality.
|
||||
},
|
||||
"import + children",
|
||||
@@ -1088,7 +1088,7 @@ async function main(): Promise<void> {
|
||||
|
||||
for (const gs of goalSubs) {
|
||||
if (!existingGoalIds.has(gs.goalID)) {
|
||||
console.warn(` [goal_sub] Skipping — goal ${gs.goalID} not found in DB`);
|
||||
console.warn(` [goal_sub] Skipping - goal ${gs.goalID} not found in DB`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1170,7 +1170,7 @@ async function main(): Promise<void> {
|
||||
const skipped = folderViews.length - viewRows.length;
|
||||
|
||||
if (skipped > 0) {
|
||||
console.warn(` [folder_view] Skipping ${skipped} row(s) — folder not found in DB`);
|
||||
console.warn(` [folder_view] Skipping ${skipped} row(s) - folder not found in DB`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < viewRows.length; i = i + INSERT_CHUNK) {
|
||||
@@ -1207,7 +1207,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// LEVEL 2 — Depend on level 1 tables
|
||||
// LEVEL 2 - Depend on level 1 tables
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n── Level 2 ──────────────────────────────────────────────────────");
|
||||
@@ -1244,7 +1244,7 @@ async function main(): Promise<void> {
|
||||
console.warn(
|
||||
` ${
|
||||
apiTokens.length - validTokens.length
|
||||
} API tokens skipped — null token/userID or deleted oauth2 client`,
|
||||
} API tokens skipped - null token/userID or deleted oauth2 client`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1332,7 +1332,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
if (skipped > 0) {
|
||||
console.warn(` [import_timing] Skipped ${skipped} row(s) — import not found`);
|
||||
console.warn(` [import_timing] Skipped ${skipped} row(s) - import not found`);
|
||||
}
|
||||
|
||||
await batchInsert("import_timing", rows);
|
||||
@@ -1341,7 +1341,7 @@ async function main(): Promise<void> {
|
||||
);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// LEVEL 3 — Large collections: scores and PBs (cursor-streamed)
|
||||
// LEVEL 3 - Large collections: scores and PBs (cursor-streamed)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n── Level 3 (streaming) ──────────────────────────────────────────");
|
||||
@@ -1397,7 +1397,7 @@ async function main(): Promise<void> {
|
||||
|
||||
if (chartSid === undefined) {
|
||||
throw new Error(
|
||||
` [score] Skipping score ${s.scoreID} — no sid for chartID ${s.chartID}`,
|
||||
` [score] Skipping score ${s.scoreID} - no sid for chartID ${s.chartID}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ import { collectDefaultMetrics, Counter, Histogram, Registry } from "prom-client
|
||||
/** Dedicated listener for `GET /metrics` (see `main.ts`). */
|
||||
export const METRICS_PORT = 9779;
|
||||
|
||||
/** Seconds — aligns with typical Prometheus HTTP latency buckets, with extra resolution under 100ms. */
|
||||
/** Seconds - aligns with typical Prometheus HTTP latency buckets, with extra resolution under 100ms. */
|
||||
const HTTP_DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
|
||||
|
||||
/** Seconds — score imports can run from sub-second to tens of minutes. */
|
||||
/** Seconds - score imports can run from sub-second to tens of minutes. */
|
||||
const SCORE_IMPORT_DURATION_BUCKETS = [0.25, 0.5, 1, 2, 5, 10, 30, 60, 120, 300, 600, 1800];
|
||||
|
||||
let scoreImportDurationSeconds: Histogram | null = null;
|
||||
|
||||
@@ -13,7 +13,7 @@ export const GetClientFromID: RequestHandler = async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Strip the client secret — this middleware is used for public lookups.
|
||||
// Strip the client secret - this middleware is used for public lookups.
|
||||
const { clientSecret: _secret, ...publicClient } = client;
|
||||
|
||||
REQ_AssignToReqTachiData(req, { apiClientDoc: publicClient });
|
||||
|
||||
@@ -244,7 +244,7 @@ describe("GET /api/v1/clients/:clientID", () => {
|
||||
// ─── PATCH /api/v1/clients/:clientID ─────────────────────────────────────────
|
||||
|
||||
describe("PATCH /api/v1/clients/:clientID", () => {
|
||||
/** Prudence `?string` fields — omit vs null is not optional; send null when not updating. */
|
||||
/** Prudence `?string` fields - omit vs null is not optional; send null when not updating. */
|
||||
const nullTemplateAndUris = {
|
||||
apiKeyTemplate: null,
|
||||
redirectUri: null,
|
||||
|
||||
+1
-1
@@ -91,5 +91,5 @@ describe("POST /api/v1/games/:game/charts/resolve", () => {
|
||||
});
|
||||
|
||||
describe("GET /api/v1/games/:game/charts (popular & search)", () => {
|
||||
it.todo("requires large IIDX chart/PB corpus — port from router.oldtest.ts");
|
||||
it.todo("requires large IIDX chart/PB corpus - port from router.oldtest.ts");
|
||||
});
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ describe("GET /api/v1/games/:game", () => {
|
||||
});
|
||||
|
||||
it.todo(
|
||||
"GET /api/v1/games/:v3Game stats (chart/player/score): blocked — Express matches GET /games/:gameGroup before GET /games/:game, so iidx-sp is rejected as a game group",
|
||||
"GET /api/v1/games/:v3Game stats (chart/player/score): blocked - Express matches GET /games/:gameGroup before GET /games/:game, so iidx-sp is rejected as a game group",
|
||||
);
|
||||
|
||||
it("returns 400 for an unsupported game slug", async () => {
|
||||
|
||||
+1
-1
@@ -141,7 +141,7 @@ describe("GET /api/v1/games/iidx-sp/songs/:songID", () => {
|
||||
});
|
||||
|
||||
it("only returns charts for the requested playtype", async () => {
|
||||
// Insert a DP chart for the same song — should not appear in the SP response.
|
||||
// Insert a DP chart for the same song - should not appear in the SP response.
|
||||
await DB.insertInto("chart")
|
||||
.values({
|
||||
id: "C_TEST_IIDX_DP_CHART",
|
||||
|
||||
@@ -167,6 +167,66 @@ describe("GET /api/v1/sessions/:sessionID/folder-raises", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/v1/sessions/:sessionID/adjacent", () => {
|
||||
async function seedThreeSessions() {
|
||||
const { id: userId } = await seedUser({ username: "adjacent_user" });
|
||||
|
||||
const sessionIds = ["adj_session_oldest", "adj_session_middle", "adj_session_newest"];
|
||||
|
||||
for (const [i, id] of sessionIds.entries()) {
|
||||
const t = new Date(2024, 0, 1 + i).toISOString();
|
||||
|
||||
await DB.insertInto("session")
|
||||
.values({
|
||||
id,
|
||||
user_id: userId,
|
||||
game: "iidx-sp",
|
||||
name: `Session ${i}`,
|
||||
description: null,
|
||||
time_inserted: t,
|
||||
time_started: t,
|
||||
time_ended: t,
|
||||
calculated_data: JSON.stringify({}),
|
||||
highlight: false,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
return { userId, sessionIds };
|
||||
}
|
||||
|
||||
it("returns both neighbors for the middle session", async () => {
|
||||
const { sessionIds } = await seedThreeSessions();
|
||||
|
||||
const res = await mockApi.get(`/api/v1/sessions/${sessionIds[1]}/adjacent`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.body.prev?.sessionID).toBe(sessionIds[0]);
|
||||
expect(res.body.body.next?.sessionID).toBe(sessionIds[2]);
|
||||
});
|
||||
|
||||
it("returns null next for the newest session", async () => {
|
||||
const { sessionIds } = await seedThreeSessions();
|
||||
|
||||
const res = await mockApi.get(`/api/v1/sessions/${sessionIds[2]}/adjacent`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.body.next).toBeNull();
|
||||
expect(res.body.body.prev?.sessionID).toBe(sessionIds[1]);
|
||||
});
|
||||
|
||||
it("returns null prev for the oldest session", async () => {
|
||||
const { sessionIds } = await seedThreeSessions();
|
||||
|
||||
const res = await mockApi.get(`/api/v1/sessions/${sessionIds[0]}/adjacent`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.body.prev).toBeNull();
|
||||
expect(res.body.body.next?.sessionID).toBe(sessionIds[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/v1/sessions/:sessionID", () => {
|
||||
it("updates the session name when authorised", async () => {
|
||||
const { sessionId, userId } = await seedSessionFixture();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user