rebel shell

This commit is contained in:
zk
2026-05-08 16:54:34 +00:00
parent 03ad9a7dfd
commit efcd7652ae
148 changed files with 1317 additions and 514 deletions
+1 -1
View File
@@ -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 -1
View File
@@ -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.
+2 -2
View File
@@ -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
@@ -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">
&laquo; Prev
</LinkButton>
) : (
<Button disabled variant="outline-secondary">
&laquo; 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 &raquo;
</LinkButton>
) : (
<Button disabled variant="outline-secondary">
Next &raquo;
</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>
) : (
+2 -2
View File
@@ -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.
-1
View File
@@ -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>;
-13
View File
@@ -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.`,
+1 -1
View File
@@ -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.`,
);
}
+1 -1
View File
@@ -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));
+1 -1
View File
@@ -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)}...`
+4 -4
View File
@@ -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>;
+13 -4
View File
@@ -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,
+1 -1
View File
@@ -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 -1
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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)",
},
+1 -1
View File
@@ -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>;
+1 -1
View File
@@ -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}
+2 -2
View File
@@ -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"> &lt;{commit.author.email}&gt;</span>
) : null}
+2 -2
View File
@@ -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;
+7 -7
View File
@@ -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.
// ---------------------------------------------------------------------------
+4 -4
View File
@@ -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 -1
View File
@@ -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
-1
View File
@@ -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
+4 -5
View File
@@ -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=
-1
View File
@@ -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
-18
View File
@@ -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();
});
});
+63 -9
View File
@@ -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();
}
+8 -5
View File
@@ -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,
+13 -7
View File
@@ -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;
+3 -3
View File
@@ -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" }];
+3 -2
View File
@@ -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");
@@ -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;
}
@@ -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);
});
+1 -1
View File
@@ -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",
}),
+28 -36
View File
@@ -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";
}
+3 -3
View File
@@ -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}`,
);
}
+2 -2
View File
@@ -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,
@@ -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");
});
@@ -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 () => {
@@ -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