mirror of
https://github.com/zkldi/Tachi.git
synced 2026-09-27 17:38:11 +03:00
feat: ultrafast tests (#1511)
* feat: ultrafast tests * fix: lint * fix: misc fixes and remove this dense perf code * fix: update server.yml appropriately * fix: nice one * fix: improve our luck
This commit is contained in:
@@ -99,7 +99,7 @@ jobs:
|
||||
VITE_EAG_CLIENT_ID: "A52JhudyAPK1KdBS3NrUhNsn"
|
||||
VITE_FLO_CLIENT_ID: "9krYLjq1rz9icCefO6OWxoMk"
|
||||
VITE_MIN_CLIENT_ID: "A0DBDBB063CD800530EF01C6488B282137E0191E"
|
||||
VITE_GIT_REPO: "GitHub:zkldi/Tachi3"
|
||||
VITE_GIT_REPO: "GitHub:zkldi/Tachi"
|
||||
VITE_RECAPTCHA_KEY: "6LcsYbIpAAAAAEJffjIXmbQcxj_SBZG7BnSPjF4L"
|
||||
TACHI_NAME: "Tachi Dev"
|
||||
BUILD_OUT_DIR: /tmp/dev
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
- name: Production build
|
||||
run: bun run --filter tachi-seeds-webui build
|
||||
env:
|
||||
VITE_SEEDS_REPO: zkldi/Tachi3
|
||||
VITE_SEEDS_REPO: zkldi/Tachi
|
||||
BUILD_OUT_DIR: /tmp/seeds-webui
|
||||
|
||||
- name: Bundle build output
|
||||
|
||||
@@ -41,17 +41,28 @@ jobs:
|
||||
env:
|
||||
NODE_ENV: "test"
|
||||
services:
|
||||
tachi-postgres:
|
||||
# Test Postgres mirrors `tachi-postgres-test` in docker-compose-dev.yml:
|
||||
# a tmpfs-backed PG sized for ~8 long-lived worker DBs with autovacuum
|
||||
# on. `PGDATA` must be a SUBDIRECTORY of the tmpfs mount, otherwise
|
||||
# initdb refuses to clobber the mount root. The runtime-tunable knobs
|
||||
# (synchronous_commit, WAL ceiling, autovacuum naptime, etc.) are
|
||||
# applied via `ALTER SYSTEM` in a setup step below; restart-required
|
||||
# knobs (fsync, full_page_writes, shared_buffers, ...) we leave at
|
||||
# the postgres defaults because `services:` does not let us pass
|
||||
# postgres CLI args. With tmpfs, fsync is essentially free anyway.
|
||||
tachi-postgres-test:
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_USER: tachi
|
||||
POSTGRES_PASSWORD: tachi
|
||||
POSTGRES_DB: postgres
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U tachi"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
--tmpfs /var/lib/postgresql/data:rw,size=3g
|
||||
tachi-redis:
|
||||
image: redis:7.4-alpine
|
||||
options: >-
|
||||
@@ -92,11 +103,36 @@ jobs:
|
||||
- name: Typecheck code
|
||||
run: bun run --filter tachi-server typecheck
|
||||
|
||||
- name: Tune test Postgres for short-lived worker DBs
|
||||
# The restart-only knobs (fsync, shared_buffers, ...) cannot be set
|
||||
# because the GHA service syntax does not let us pass postgres CLI
|
||||
# args. Everything below is reloadable; it lines up with the
|
||||
# `-c ...` flags on `tachi-postgres-test` in docker-compose-dev.yml
|
||||
# so CI and local behave the same once the suite starts running.
|
||||
env:
|
||||
PGPASSWORD: tachi
|
||||
run: |
|
||||
psql -h tachi-postgres-test -U tachi -d postgres <<'SQL'
|
||||
ALTER SYSTEM SET synchronous_commit = off;
|
||||
ALTER SYSTEM SET max_wal_size = '128MB';
|
||||
ALTER SYSTEM SET min_wal_size = '32MB';
|
||||
ALTER SYSTEM SET checkpoint_timeout = '30s';
|
||||
ALTER SYSTEM SET autovacuum_naptime = '5s';
|
||||
ALTER SYSTEM SET autovacuum_vacuum_scale_factor = 0.05;
|
||||
ALTER SYSTEM SET autovacuum_analyze_scale_factor = 0.1;
|
||||
SELECT pg_reload_conf();
|
||||
SQL
|
||||
|
||||
- name: Run tests
|
||||
run: bun run --filter tachi-server test
|
||||
env:
|
||||
NODE_ENV: "test"
|
||||
PORT: 8080
|
||||
POSTGRES_TEST_HOST: tachi-postgres-test
|
||||
POSTGRES_TEST_URL: postgresql://tachi:tachi@tachi-postgres-test
|
||||
# ubuntu-latest is 4 vCPU; cap workers to leave headroom for the
|
||||
# PG service container and avoid oversubscribing tmpfs.
|
||||
VITEST_MAX_WORKERS: "4"
|
||||
run: bun run --filter tachi-server test
|
||||
|
||||
docker-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
POSTGRES_URL := "postgresql://tachi:tachi@tachi-postgres"
|
||||
# Test Postgres lives on tmpfs with durability disabled (docker-compose-dev.yml,
|
||||
# `tachi-postgres-test` service). Defaults to the dev Postgres so tests still run
|
||||
# in environments without the dedicated -test service; override via env var.
|
||||
POSTGRES_TEST_URL := env_var_or_default("POSTGRES_TEST_URL", "postgresql://tachi:tachi@tachi-postgres")
|
||||
DEFAULT_DB := "tachi_dev"
|
||||
|
||||
# Open a psql shell against the local dev Postgres instance.
|
||||
|
||||
+12
-4
@@ -47,12 +47,10 @@ autofix:
|
||||
fmt:
|
||||
bun biome format --write
|
||||
bun prettier --write --list-different .
|
||||
# cd deploy/infra && tofu fmt -diff
|
||||
|
||||
fmt-check:
|
||||
bun biome format
|
||||
bun prettier --check .
|
||||
# cd deploy/infra && tofu fmt --check -diff
|
||||
|
||||
test FILTER="*":
|
||||
#!/bin/bash
|
||||
@@ -68,6 +66,12 @@ test-typescript FILTER="*":
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# `tachi-postgres-test` (tmpfs + fsync=off, see docker-compose-dev.yml) is the
|
||||
# fast path; falls back to the dev PG if the dedicated service isn't running.
|
||||
export POSTGRES_TEST_HOST="${POSTGRES_TEST_HOST:-tachi-postgres-test}"
|
||||
# Keep POSTGRES_TEST_URL in sync so the `*-db-test-template-reset` recipes
|
||||
# (called from vitest.globalSetup.ts) hit the same host the workers will.
|
||||
export POSTGRES_TEST_URL="${POSTGRES_TEST_URL:-postgresql://tachi:tachi@${POSTGRES_TEST_HOST}}"
|
||||
bun run --elide-lines=0 --sequential --filter '{{FILTER}}' test -- --reporter=default --reporter=junit --outputFile.junit=test-results/junit.xml
|
||||
|
||||
# Summarize Vitest v8 coverage across workspaces (see typescript/coverage-tools).
|
||||
@@ -83,20 +87,24 @@ test-parity suite="":
|
||||
|
||||
# Create (or recreate) the bot test template database with all migrations applied.
|
||||
# Workers clone from this template rather than re-running migrations each time.
|
||||
# Targets the dedicated tmpfs `tachi-postgres-test` server (override via
|
||||
# POSTGRES_TEST_URL).
|
||||
bot-db-test-template-reset:
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
export POSTGRES_URL="{{POSTGRES_URL}}/tachi_bot_test_template"
|
||||
export POSTGRES_URL="{{POSTGRES_TEST_URL}}/tachi_bot_test_template"
|
||||
tachidb database drop
|
||||
tachidb database create
|
||||
tachidb migrate run
|
||||
|
||||
# Create (or recreate) the server test template database with all migrations applied.
|
||||
# Workers clone from this template rather than re-running migrations each time.
|
||||
# Targets the dedicated tmpfs `tachi-postgres-test` server (override via
|
||||
# POSTGRES_TEST_URL).
|
||||
server-db-test-template-reset:
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
export POSTGRES_URL="{{POSTGRES_URL}}/tachi_server_test_template"
|
||||
export POSTGRES_URL="{{POSTGRES_TEST_URL}}/tachi_server_test_template"
|
||||
tachidb database drop
|
||||
tachidb database create
|
||||
tachidb migrate run
|
||||
|
||||
@@ -51,6 +51,69 @@ services:
|
||||
volumes:
|
||||
- tachi-postgres:/var/lib/postgresql
|
||||
- "./dev/postgres-init.sql:/docker-entrypoint-initdb.d/init.sql"
|
||||
|
||||
# Dedicated test Postgres. Lives on tmpfs with durability disabled - every
|
||||
# `CREATE DATABASE ... TEMPLATE`, every `TRUNCATE`, every commit is ~5-20x
|
||||
# faster than the dev DB. Wiped on container restart, which is exactly what
|
||||
# we want for tests. Use POSTGRES_TEST_HOST in vitest setup to point at it.
|
||||
tachi-postgres-test:
|
||||
container_name: tachi-postgres-test
|
||||
image: postgres:18
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5433:5432"
|
||||
# NOTE: with `pool: "threads" + isolate: false` (vitest.config.ts) the
|
||||
# vitest worker DBs are long-lived (~25-30 files of writes each), so
|
||||
# autovacuum + a small WAL ceiling + a working bgwriter are NEEDED to
|
||||
# avoid filling the tmpfs. Earlier we shipped autovacuum=off +
|
||||
# max_wal_size=1GB + bgwriter_lru_maxpages=0 (which was fine for the
|
||||
# short-lived per-file DBs of isolate:true) and tests started failing
|
||||
# mid-run with `No space left on device`.
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- fsync=off
|
||||
- -c
|
||||
- synchronous_commit=off
|
||||
- -c
|
||||
- full_page_writes=off
|
||||
- -c
|
||||
- wal_level=minimal
|
||||
- -c
|
||||
- max_wal_senders=0
|
||||
- -c
|
||||
- shared_buffers=256MB
|
||||
- -c
|
||||
- max_connections=200
|
||||
- -c
|
||||
- max_wal_size=128MB
|
||||
- -c
|
||||
- min_wal_size=32MB
|
||||
- -c
|
||||
- checkpoint_timeout=30s
|
||||
- -c
|
||||
- autovacuum=on
|
||||
- -c
|
||||
- autovacuum_naptime=5s
|
||||
- -c
|
||||
- autovacuum_vacuum_scale_factor=0.05
|
||||
- -c
|
||||
- autovacuum_analyze_scale_factor=0.1
|
||||
- -c
|
||||
- shared_preload_libraries=pg_stat_statements
|
||||
- -c
|
||||
- pg_stat_statements.track=all
|
||||
# Sized for ~16 concurrent worker DBs + template + WAL headroom.
|
||||
# Each worker DB clone is ~150-250 MB post-migration, and `CREATE
|
||||
# DATABASE ... TEMPLATE` can briefly double that during the copy.
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data:rw,size=6g
|
||||
environment:
|
||||
POSTGRES_USER: tachi
|
||||
POSTGRES_PASSWORD: tachi
|
||||
POSTGRES_DB: postgres
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
|
||||
tachi-s3:
|
||||
container_name: tachi-s3
|
||||
image: quay.io/minio/minio:RELEASE.2024-10-29T16-01-48Z
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import useSetSubheader from "#components/layout/header/useSetSubheader";
|
||||
import useApiQuery from "#components/util/query/useApiQuery";
|
||||
import { ADMIN_PAGE_SIZE } from "#lib/adminConstants";
|
||||
import { ADMIN_RECENT_HOURS } from "#lib/adminConstants";
|
||||
import { MillisToSince } from "#util/time";
|
||||
import React from "react";
|
||||
import { Badge, Button, Form, Table } from "react-bootstrap";
|
||||
@@ -92,7 +92,8 @@ export default function AdminActionsPage() {
|
||||
}
|
||||
|
||||
const { actions, filters } = data;
|
||||
const totalPages = Math.ceil(actions.total / ADMIN_PAGE_SIZE);
|
||||
const pageSize = actions.pageSize;
|
||||
const totalPages = Math.ceil(actions.total / pageSize);
|
||||
const currentPage = actions.page;
|
||||
|
||||
function buildPageUrl(p: number) {
|
||||
@@ -122,6 +123,9 @@ export default function AdminActionsPage() {
|
||||
<h2 className="h5">
|
||||
Actions <span className="badge bg-secondary">{actions.total.toLocaleString()}</span>
|
||||
</h2>
|
||||
<p className="small text-body-secondary mb-0">
|
||||
Actions from the last {ADMIN_RECENT_HOURS} hours (up to {pageSize} per page).
|
||||
</p>
|
||||
|
||||
<Form className="d-flex flex-wrap align-items-end gap-3" onSubmit={onFilterSubmit}>
|
||||
<Form.Group>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import useSetSubheader from "#components/layout/header/useSetSubheader";
|
||||
import useApiQuery from "#components/util/query/useApiQuery";
|
||||
import { ADMIN_PAGE_SIZE, JOB_STATUS } from "#lib/adminConstants";
|
||||
import { ADMIN_PAGE_SIZE, ADMIN_RECENT_HOURS, JOB_STATUS } from "#lib/adminConstants";
|
||||
import { MillisToSince } from "#util/time";
|
||||
import React from "react";
|
||||
import { Button, Form, Table } from "react-bootstrap";
|
||||
@@ -84,8 +84,10 @@ export default function AdminJobQueuePage() {
|
||||
}
|
||||
|
||||
const { activeJobs, filters, jobQueue } = data;
|
||||
const totalPages = Math.ceil(jobQueue.total / ADMIN_PAGE_SIZE);
|
||||
const pageSize = jobQueue.pageSize;
|
||||
const totalPages = Math.ceil(jobQueue.total / pageSize);
|
||||
const currentPage = jobQueue.page;
|
||||
const activeJobsTruncated = activeJobs.length >= ADMIN_PAGE_SIZE;
|
||||
|
||||
function buildPageUrl(p: number) {
|
||||
const sp = new URLSearchParams(location.search);
|
||||
@@ -120,6 +122,12 @@ export default function AdminJobQueuePage() {
|
||||
<h2 className="h5">
|
||||
Active jobs <span className="badge bg-primary">{activeJobs.length}</span>
|
||||
</h2>
|
||||
{activeJobsTruncated && (
|
||||
<p className="small text-body-secondary mb-2">
|
||||
Showing the first {ADMIN_PAGE_SIZE} running jobs (oldest scheduled
|
||||
first).
|
||||
</p>
|
||||
)}
|
||||
<div className="table-responsive">
|
||||
<Table hover size="sm" striped>
|
||||
<thead>
|
||||
@@ -146,9 +154,12 @@ export default function AdminJobQueuePage() {
|
||||
|
||||
<section>
|
||||
<h2 className="h5">
|
||||
All jobs{" "}
|
||||
Recent jobs{" "}
|
||||
<span className="badge bg-secondary">{jobQueue.total.toLocaleString()}</span>
|
||||
</h2>
|
||||
<p className="small text-body-secondary mb-3">
|
||||
Jobs created in the last {ADMIN_RECENT_HOURS} hours (up to {pageSize} per page).
|
||||
</p>
|
||||
|
||||
<Form
|
||||
className="d-flex flex-wrap align-items-end gap-3 mb-3"
|
||||
|
||||
@@ -6,4 +6,8 @@ export const JOB_STATUS: Record<number, string> = {
|
||||
3: "Failed",
|
||||
};
|
||||
|
||||
/** Matches server `ADMIN_PAGE_SIZE` in admin-queries.ts */
|
||||
export const ADMIN_PAGE_SIZE = 50;
|
||||
|
||||
/** Matches server `ADMIN_RECENT_HOURS` in admin-queries.ts */
|
||||
export const ADMIN_RECENT_HOURS = 12;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Test stuffs
|
||||
.setup_env
|
||||
failed-tests.txt
|
||||
.vite-cache
|
||||
|
||||
# Error Files
|
||||
scripts/validate-database-errs/*
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Action, CronTask, CronTaskExecution, JobQueue } from "tachi-db";
|
||||
|
||||
import { ONE_HOUR } from "#lib/constants/time";
|
||||
import {
|
||||
SELECT_CRON_TASK,
|
||||
SELECT_CRON_TASK_EXECUTION,
|
||||
@@ -9,6 +10,13 @@ import DB from "#services/pg/db";
|
||||
|
||||
export const ADMIN_PAGE_SIZE = 50;
|
||||
|
||||
/** Only list job queue rows and actions from this many hours ago (inclusive). */
|
||||
export const ADMIN_RECENT_HOURS = 12;
|
||||
|
||||
export function adminRecentSinceIso(hours = ADMIN_RECENT_HOURS): string {
|
||||
return new Date(Date.now() - ONE_HOUR * hours).toISOString();
|
||||
}
|
||||
|
||||
export interface JobQueueFilters {
|
||||
job_kind?: string;
|
||||
scope?: string;
|
||||
@@ -22,24 +30,26 @@ export interface PaginatedResult<T> {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function GetActiveJobs(): Promise<Array<JobQueue>> {
|
||||
/** Currently running jobs (not limited by the recent-hours window). */
|
||||
export function GetActiveJobs(limit = ADMIN_PAGE_SIZE): Promise<Array<JobQueue>> {
|
||||
return DB.selectFrom("job_queue")
|
||||
.select(SELECT_JOB_QUEUE)
|
||||
.where("job_queue.status", "=", 1)
|
||||
.orderBy("job_queue.scheduled_for", "asc")
|
||||
.limit(limit)
|
||||
.execute();
|
||||
}
|
||||
|
||||
function jobQueueBaseQuery(filters: JobQueueFilters) {
|
||||
let q = DB.selectFrom("job_queue");
|
||||
let q = DB.selectFrom("job_queue").where("job_queue.created_at", ">=", adminRecentSinceIso());
|
||||
if (filters.status !== undefined) {
|
||||
q = q.where("status", "=", filters.status);
|
||||
q = q.where("job_queue.status", "=", filters.status);
|
||||
}
|
||||
if (filters.job_kind) {
|
||||
q = q.where("job_kind", "=", filters.job_kind);
|
||||
q = q.where("job_queue.job_kind", "=", filters.job_kind);
|
||||
}
|
||||
if (filters.scope) {
|
||||
q = q.where("scope", "=", filters.scope);
|
||||
q = q.where("job_queue.scope", "=", filters.scope);
|
||||
}
|
||||
return q;
|
||||
}
|
||||
@@ -82,7 +92,9 @@ export interface ActionFilters {
|
||||
export type ActionRow = { username: string | null } & Action;
|
||||
|
||||
function actionFilteredQuery(filters: ActionFilters) {
|
||||
let q = DB.selectFrom("action").leftJoin("account", "account.id", "action.user_id");
|
||||
let q = DB.selectFrom("action")
|
||||
.leftJoin("account", "account.id", "action.user_id")
|
||||
.where("action.ts_start", ">=", adminRecentSinceIso());
|
||||
if (filters.kind) {
|
||||
q = q.where("action.kind", "=", filters.kind);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { log } from "#lib/log/log";
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import { Env, ServerConfig } from "#lib/setup/config";
|
||||
import DB from "#services/pg/db";
|
||||
import nodeFetch from "#utils/fetch";
|
||||
import { Random20Hex } from "#utils/misc";
|
||||
@@ -17,8 +17,6 @@ import {
|
||||
} from "tachi-common";
|
||||
import { type Database } from "tachi-db";
|
||||
|
||||
const BCRYPT_SALT_ROUNDS = 12;
|
||||
|
||||
export const ValidatePassword = (self: unknown) =>
|
||||
(typeof self === "string" && self.length >= 8) || "Passwords must be 8 characters or more.";
|
||||
|
||||
@@ -83,7 +81,7 @@ export const DEFAULT_USER_SETTINGS: UserSettingsDocument["preferences"] = {
|
||||
};
|
||||
|
||||
export function HashPassword(plaintext: string) {
|
||||
return bcrypt.hash(plaintext, BCRYPT_SALT_ROUNDS);
|
||||
return bcrypt.hash(plaintext, Env.BCRYPT_SALT_ROUNDS);
|
||||
}
|
||||
|
||||
export async function AddNewUser(
|
||||
|
||||
@@ -585,6 +585,26 @@ if (!versionDetail) {
|
||||
*/
|
||||
const PG_POOL_MAX = parseIntEnv("PG_POOL_MAX", 10);
|
||||
|
||||
/**
|
||||
* Cost factor for `bcrypt.hash` (and the round-trip cost of `bcrypt.compare`
|
||||
* against any resulting hash). Defaults to 12 - the value the codebase shipped
|
||||
* with - and is enforced as a minimum outside test environments.
|
||||
*
|
||||
* In tests we want bcrypt to be effectively free: the test suite hashes /
|
||||
* verifies dozens of passwords and at 12 rounds bcrypt dominates the auth-
|
||||
* flavoured test budget. Set via TACHI_BCRYPT_SALT_ROUNDS (defaults to the
|
||||
* bcryptjs minimum, 4, when NODE_ENV=test).
|
||||
*/
|
||||
const BCRYPT_SALT_ROUNDS = parseIntEnv("TACHI_BCRYPT_SALT_ROUNDS", NODE_ENV === "test" ? 4 : 12);
|
||||
|
||||
if (NODE_ENV !== "test" && BCRYPT_SALT_ROUNDS < 12) {
|
||||
log.fatal(
|
||||
`TACHI_BCRYPT_SALT_ROUNDS=${BCRYPT_SALT_ROUNDS} but NODE_ENV=${NODE_ENV}. ` +
|
||||
`Refusing to start: rounds below 12 are only acceptable in test environments.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export const Env = {
|
||||
PORT,
|
||||
REDIS_URL,
|
||||
@@ -595,4 +615,5 @@ export const Env = {
|
||||
NODE_ENV: NODE_ENV as "dev" | "production" | "staging" | "test",
|
||||
LOG_LEVEL: logLevel as "crit" | "debug" | "error" | "info" | "severe" | "verbose" | "warn",
|
||||
PG_POOL_MAX,
|
||||
BCRYPT_SALT_ROUNDS,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,14 @@ import { OmitUndefinedKeys } from "#utils/misc";
|
||||
import rateLimit, { type Options } from "express-rate-limit";
|
||||
import RateLimitRedis from "rate-limit-redis";
|
||||
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
// Signal to vitest.setup.ts that this worker has loaded the rate limiter,
|
||||
// so its beforeEach should clear the in-memory cache. Pure-unit test files
|
||||
// that never load any router skip the cache-clear (and the import chain)
|
||||
// entirely.
|
||||
(globalThis as { __tachi_rate_limiter_loaded?: boolean }).__tachi_rate_limiter_loaded = true;
|
||||
}
|
||||
|
||||
function CreateStore(name: string) {
|
||||
// undefined forces a default to an in-memory store
|
||||
// So we use that when in testing or localdev.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* `API_V1_ROUTER` is the singleton TypedRouter that every `/api/v1/...`
|
||||
* submodule registers its routes on. It lives in its own module (with no
|
||||
* transitive imports of any submodule) so that:
|
||||
*
|
||||
* 1. `router.ts` can synchronously `import "./sub/router"` every submodule
|
||||
* to register routes as a side effect, then call `API_V1_ROUTER.build()`.
|
||||
* 2. The submodules' `import { API_V1_ROUTER } from "../_singleton"` does
|
||||
* NOT cycle through `router.ts` (the previous setup had submodules
|
||||
* importing `../router`, which made the only way to break the cycle
|
||||
* `await import(...)` + top-level await in `router.ts` - and vitest's
|
||||
* vite-node under `pool: "threads" + isolate: false` does not reliably
|
||||
* wait for TLA before serving a module's exports to a static import).
|
||||
*
|
||||
* Keep this module dependency-light: it must not, transitively, import
|
||||
* anything that itself imports any of the route submodules.
|
||||
*/
|
||||
|
||||
import { TypedRouter } from "#lib/router/typed-router";
|
||||
|
||||
import { API_V1_SPEC } from "./spec";
|
||||
|
||||
export const API_V1_ROUTER = new TypedRouter(API_V1_SPEC);
|
||||
@@ -2,7 +2,7 @@ import { GetRecentActivityForMultipleGames } from "#lib/activity/activity";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { ALL_GAMES } from "tachi-common";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
async function globalActivityImpl(input: { startTime?: number }) {
|
||||
const data = await GetRecentActivityForMultipleGames(
|
||||
|
||||
@@ -24,7 +24,7 @@ import { GetUserWithIDGuaranteed, ResolveUser } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { GameToGameGroup, type V3Game } from "tachi-common";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
API_V1_ROUTER.add("GET /admin/job-queue", withAdmin, async ({ input }) => {
|
||||
const page = Math.max(0, input.page ?? 0);
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
const aggressiveRL = wrapExpressMiddleware(AggressiveRateLimitMiddleware);
|
||||
const hyperAggressiveRL = wrapExpressMiddleware(HyperAggressiveRateLimitMiddleware);
|
||||
|
||||
@@ -9,7 +9,7 @@ import DB from "#services/pg/db";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { type APIPermissions } from "tachi-common";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
/**
|
||||
* Retrieve the clients you created. Must be performed with a session-level request.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { ServerConfig, TachiConfig } from "#lib/setup/config";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
/**
|
||||
* Returns Tachi Configuration info, such as server name, type, supported games
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "#lib/game-specific/custom-bms-tables";
|
||||
import { withGame } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { FindBMSSieglindeRatedCharts } from "#utils/queries/charts";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { type GamesForGroup, GameToGameGroup } from "tachi-common";
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from "#lib/game-specific/iidx-playlists";
|
||||
import { withGame } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { type GamesForGroup, GameToGameGroup } from "tachi-common";
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ import {
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
const gptStatCache = new NodeCache();
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { FormatUserDoc, GetUserWithIDGuaranteed } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { p } from "prudence";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
const ParseMultipartScoredata = CreateMulterSingleUploadMiddleware("scoreData", SIXTEEN_MEGABTYES);
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { GetRelevantSongsAndCharts } from "#utils/db";
|
||||
import { GetUsersWithIDs, GetUserWithID } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
// ─── Admin-facing import list ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import DB from "#services/pg/db";
|
||||
import { GetFirstAdmin } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
/**
|
||||
* Reports whether the database has any rows in the `song` table (seed data).
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ANON_ACTION_OAuthTokenExchange } from "#anon-actions/oauth-token-exchan
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
/**
|
||||
* Converts an auth code into a valid API key that is returned.
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
} from "#lib/proposals/github";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
|
||||
@@ -1,64 +1,69 @@
|
||||
import { TypedRouter } from "#lib/router/typed-router";
|
||||
// `API_V1_ROUTER` is the singleton route registry; it lives in `./_singleton`
|
||||
// so the submodules below can import it without cycling back through this
|
||||
// file (the old `await import(...)` + top-level-await scheme was the only
|
||||
// way to break the previous cycle, and vitest's vite-node under `pool:
|
||||
// "threads" + isolate: false` does not reliably await TLA before serving a
|
||||
// module's exports to a static `import` somewhere up the tree).
|
||||
import { API_V1_ROUTER } from "./_singleton";
|
||||
|
||||
import { API_V1_SPEC } from "./spec";
|
||||
export { API_V1_ROUTER };
|
||||
|
||||
export const API_V1_ROUTER = new TypedRouter(API_V1_SPEC);
|
||||
|
||||
// this sucks, but there's no "mod" tree in typescript, unlike rust.
|
||||
await import("./status/router");
|
||||
await import("./auth/router");
|
||||
await import("./admin/router");
|
||||
await import("./import/router");
|
||||
await import("./imports/router");
|
||||
await import("./users/router");
|
||||
await import("./games/router");
|
||||
await import("./games/@gameSpecificRoutes/bms/router");
|
||||
await import("./games/@gameSpecificRoutes/iidx/router");
|
||||
await import("./search/router");
|
||||
await import("./sessions/router");
|
||||
await import("./sessions/_sessionID/router");
|
||||
await import("./oauth/router");
|
||||
await import("./clients/router");
|
||||
await import("./activity/router");
|
||||
await import("./config/router");
|
||||
await import("./localdev/router");
|
||||
await import("./seeds/router");
|
||||
await import("./proposals/router");
|
||||
await import("./scores/_scoreID/router");
|
||||
|
||||
await import("./users/_userID/router");
|
||||
await import("./users/_userID/pfp/router");
|
||||
await import("./users/_userID/banner/router");
|
||||
await import("./users/_userID/api-tokens/router");
|
||||
await import("./users/_userID/invites/router");
|
||||
await import("./users/_userID/following/router");
|
||||
await import("./users/_userID/notifications/router");
|
||||
await import("./users/_userID/sessions/router");
|
||||
await import("./users/_userID/imports/router");
|
||||
await import("./users/_userID/settings/router");
|
||||
await import("./users/_userID/integrations/router");
|
||||
await import("./users/_userID/integrations/cg/_cgType/router");
|
||||
await import("./users/_userID/integrations/kai/_kaiType/router");
|
||||
await import("./users/_userID/integrations/fervidex/router");
|
||||
await import("./users/_userID/integrations/kshook-sv6c/router");
|
||||
await import("./users/_userID/integrations/myt/router");
|
||||
|
||||
await import("./users/_userID/games/@gameSpecificRoutes/bms/router");
|
||||
await import("./users/_userID/games/@gameSpecificRoutes/iidx/router");
|
||||
await import("./users/_userID/games/@gameSpecificRoutes/jubeat/router");
|
||||
await import("./users/_userID/games/_game/_playtype/pbs/router");
|
||||
await import("./users/_userID/games/_game/_playtype/scores/router");
|
||||
await import("./users/_userID/games/_game/_playtype/sessions/router");
|
||||
await import("./users/_userID/games/_game/_playtype/tables/router");
|
||||
await import("./users/_userID/games/_game/_playtype/showcase/router");
|
||||
await import("./users/_userID/games/_game/_playtype/settings/router");
|
||||
await import("./users/_userID/games/_game/_playtype/targets/router");
|
||||
await import("./users/_userID/games/_game/_playtype/targets/goals/router");
|
||||
await import("./users/_userID/games/_game/_playtype/targets/quests/router");
|
||||
await import("./users/_userID/games/_game/_playtype/folders/router");
|
||||
await import("./users/_userID/games/_game/_playtype/folders/_folderSlug/router");
|
||||
await import("./users/_userID/games/_game/_playtype/router");
|
||||
await import("./users/_userID/games/_game/_playtype/rivals/router");
|
||||
// Submodules register routes on `API_V1_ROUTER` as a side effect of being
|
||||
// imported. Ordering only matters where one route prefix shadows another
|
||||
// (e.g. `/users/_userID` after `/users`); within a group the order below
|
||||
// mirrors the original `await import(...)` block.
|
||||
import "./status/router";
|
||||
import "./auth/router";
|
||||
import "./admin/router";
|
||||
import "./import/router";
|
||||
import "./imports/router";
|
||||
import "./users/router";
|
||||
import "./games/router";
|
||||
import "./games/@gameSpecificRoutes/bms/router";
|
||||
import "./games/@gameSpecificRoutes/iidx/router";
|
||||
import "./search/router";
|
||||
import "./sessions/router";
|
||||
import "./sessions/_sessionID/router";
|
||||
import "./oauth/router";
|
||||
import "./clients/router";
|
||||
import "./activity/router";
|
||||
import "./config/router";
|
||||
import "./localdev/router";
|
||||
import "./seeds/router";
|
||||
import "./proposals/router";
|
||||
import "./scores/_scoreID/router";
|
||||
import "./users/_userID/router";
|
||||
import "./users/_userID/pfp/router";
|
||||
import "./users/_userID/banner/router";
|
||||
import "./users/_userID/api-tokens/router";
|
||||
import "./users/_userID/invites/router";
|
||||
import "./users/_userID/following/router";
|
||||
import "./users/_userID/notifications/router";
|
||||
import "./users/_userID/sessions/router";
|
||||
import "./users/_userID/imports/router";
|
||||
import "./users/_userID/settings/router";
|
||||
import "./users/_userID/integrations/router";
|
||||
import "./users/_userID/integrations/cg/_cgType/router";
|
||||
import "./users/_userID/integrations/kai/_kaiType/router";
|
||||
import "./users/_userID/integrations/fervidex/router";
|
||||
import "./users/_userID/integrations/kshook-sv6c/router";
|
||||
import "./users/_userID/integrations/myt/router";
|
||||
import "./users/_userID/games/@gameSpecificRoutes/bms/router";
|
||||
import "./users/_userID/games/@gameSpecificRoutes/iidx/router";
|
||||
import "./users/_userID/games/@gameSpecificRoutes/jubeat/router";
|
||||
import "./users/_userID/games/_game/_playtype/pbs/router";
|
||||
import "./users/_userID/games/_game/_playtype/scores/router";
|
||||
import "./users/_userID/games/_game/_playtype/sessions/router";
|
||||
import "./users/_userID/games/_game/_playtype/tables/router";
|
||||
import "./users/_userID/games/_game/_playtype/showcase/router";
|
||||
import "./users/_userID/games/_game/_playtype/settings/router";
|
||||
import "./users/_userID/games/_game/_playtype/targets/router";
|
||||
import "./users/_userID/games/_game/_playtype/targets/goals/router";
|
||||
import "./users/_userID/games/_game/_playtype/targets/quests/router";
|
||||
import "./users/_userID/games/_game/_playtype/folders/router";
|
||||
import "./users/_userID/games/_game/_playtype/folders/_folderSlug/router";
|
||||
import "./users/_userID/games/_game/_playtype/router";
|
||||
import "./users/_userID/games/_game/_playtype/rivals/router";
|
||||
|
||||
const router = API_V1_ROUTER.build();
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { GetSongByID } from "#lib/db-formats/song";
|
||||
import { log } from "#lib/log/log";
|
||||
import { withScore, withScoreOwner } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetUserWithID } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { GameToGameGroup } from "tachi-common";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SearchGamesSongsCharts } from "#lib/search/song-charts";
|
||||
import { GetAllUserRivals, GetUserPlayedGames } from "#utils/user";
|
||||
import { type integer, type UserDocument, type V3Game } from "tachi-common";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
/**
|
||||
* Performs a generic "search" across Tachi.
|
||||
|
||||
@@ -10,7 +10,7 @@ import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
// Routes for interacting with the `seeds` folder in this instance of Tachi.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { GetSessionFolderRaises } from "#lib/folders/get-session-folder-raises";
|
||||
import { withSession, withSessionOwner } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetAdjacentSessions, GetSessionData, GetSessionIndex } from "#utils/queries/sessions";
|
||||
import { GetUserWithID } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { VERSION_PRETTY } from "#lib/constants/version";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ACTION_DeleteApiToken } from "#actions/delete-api-token";
|
||||
import { SELECT_API_TOKEN, ToAPITokenDocument } from "#lib/db-formats/api-token";
|
||||
import { withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,7 @@ import { withAuthedAsUser, withPermission, withRequestedUser } from "#lib/router
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { RequirePermissions } from "#server/middleware/auth";
|
||||
import { CreateMulterSingleUploadMiddleware } from "#server/middleware/multer-upload";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { REQ_GetTachiData } from "#utils/req-tachi-data";
|
||||
|
||||
import { GetUserFromParam, RequireAuthedAsUser } from "../middleware";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ACTION_FollowUser } from "#actions/follow-user";
|
||||
import { ACTION_UnfollowUser } from "#actions/unfollow-user";
|
||||
import { withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetFollowingForUser } from "#utils/queries/settings";
|
||||
import { GetUsersWithIDs } from "#utils/user";
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
} from "#lib/game-specific/custom-bms-tables";
|
||||
import { withGame, withRequestedUserAndReqData } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { FindBMSChartOnHashInGame } from "#utils/queries/charts";
|
||||
import { REQ_GetUser } from "#utils/req-tachi-data";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import { withGame, withRequestedUserAndReqData } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { EAM_VERSION_NAMES } from "#lib/score-import/import-types/common/eamusement-iidx-csv/parser";
|
||||
import { AggressiveRateLimitMiddleware } from "#server/middleware/rate-limiter";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetUserFromParam } from "#server/router/api/v1/users/_userID/middleware";
|
||||
import DB from "#services/pg/db";
|
||||
import { REQ_AssignToReqTachiData, REQ_GetUser } from "#utils/req-tachi-data";
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { GetPBsForJubility } from "#game-implementations/games/jubeat";
|
||||
import { withGame, withRequestedUserAndReqData } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetRelevantSongsAndCharts } from "#utils/db";
|
||||
import { REQ_GetUser } from "#utils/req-tachi-data";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import {
|
||||
import { LoadFolderEvolutionPayload } from "#lib/folders/table-evolution";
|
||||
import { withSelf, withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { GetFolderTimelineScores } from "#utils/queries/scores";
|
||||
import { UnixMillisecondsToISO8601 } from "#utils/time";
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { GetEnumDistForFolders, GetRecentlyViewedFolders } from "#lib/folders/fo
|
||||
import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { SearchFoldersForGameFtsAndTrgm } from "#lib/search/folders";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
|
||||
/**
|
||||
* Search folders with user grade+lamp distribution.
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { ResolveSongAndChart } from "#lib/score-import/import-types/common/batch-manual/converter";
|
||||
import { SearchSpecificGameSongsAndCharts } from "#lib/search/song-charts";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetRelevantSongsAndCharts } from "#utils/db";
|
||||
import { IsValidScoreAlg } from "#utils/misc";
|
||||
import { GetAdjacentAbove, GetAdjacentBelow } from "#utils/queries/pbs";
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { LoadPbDocumentsForUserSetSortedByCalculatedAlg } from "#lib/db-formats/
|
||||
import { GetChallengerUsers, GetRivalIDs, GetRivalUsers } from "#lib/rivals/rivals";
|
||||
import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetRelevantSongsAndCharts } from "#utils/db";
|
||||
import { DedupeArr, IsString } from "#utils/misc";
|
||||
import { CheckStrScoreAlg } from "#utils/string-checks";
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import { GetSongsByIDs } from "#lib/db-formats/song";
|
||||
import { log } from "#lib/log/log";
|
||||
import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { IsString } from "#utils/misc";
|
||||
import DestroyUserGameProfile from "#utils/reset-state/destroy-user-game-profile";
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import {
|
||||
import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { SearchSpecificGameSongsAndCharts } from "#lib/search/song-charts";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { GetRelevantSongsAndCharts } from "#utils/db";
|
||||
import {
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { GetSessionScoreInfo } from "#lib/score-import/framework/sessions/sessions";
|
||||
import { SearchSessions } from "#lib/search/search";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { GetScoreIdsGroupedBySessionId } from "#utils/queries/sessions";
|
||||
import { CheckStrSessionAlg } from "#utils/string-checks";
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { GetUGPTSettingsDocument } from "#lib/db-formats/ugpt-settings";
|
||||
import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetUserWithIDGuaranteed } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { success } from "#lib/router/typed-router";
|
||||
import { EvaluateShowcaseStat } from "#lib/showcase/evaluator";
|
||||
import { GetRelatedStatDocuments } from "#lib/showcase/get-related";
|
||||
import { EvaluateUsersStatsShowcase } from "#lib/showcase/get-stats";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetUserWithIDGuaranteed, ResolveUser } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import {
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { GetEnumDistForFolders, GetFoldersFromTable } from "#lib/folders/folders
|
||||
import { LoadTableEvolutionPayload } from "#lib/folders/table-evolution";
|
||||
import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import {
|
||||
import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { GetParentQuests } from "#lib/targets/quests";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { type GoalDocument } from "tachi-common";
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { SELECT_QUEST, SELECT_QUEST_SUB_WITH_QUEST_GAME } from "#lib/db-formats/
|
||||
import { ToQuestDocument, ToQuestSubscriptionDocument } from "#lib/db-formats/target-documents";
|
||||
import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { sql } from "kysely";
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import { withUserGameProfile } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { GetRelevantGoals } from "#lib/targets/goals";
|
||||
import { GetParentQuests } from "#lib/targets/quests";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import {
|
||||
GetRecentlyAchievedGoals,
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from "#lib/db-formats/import-document";
|
||||
import { withRequestedUser } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
|
||||
/**
|
||||
* Query this user's imports. Returns the 500 most recently-finished imports.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { ACTION_UpdateCgCardInfo } from "#actions/update-cg-card-info";
|
||||
import { SELECT_CG_CARD_INFO, ToCGCardInfo } from "#lib/db-formats/cg-card-info";
|
||||
import { withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ACTION_UpdateFervidexSettings } from "#actions/update-fervidex-settings
|
||||
import { SELECT_FER_SETTINGS, ToFervidexSettingsDocument } from "#lib/db-formats/fervidex-settings";
|
||||
import { withKamaitachi, withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import {
|
||||
GetKaiTypeClientCredentials,
|
||||
KaiTypeToBaseURL,
|
||||
} from "#lib/score-import/import-types/common/api-kai/utils";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import fetch from "#utils/fetch";
|
||||
import { GetKaiAuth } from "#utils/queries/auth";
|
||||
import { FormatUserDoc } from "#utils/user";
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
} from "#lib/db-formats/kshook-sv6c-settings";
|
||||
import { withKamaitachi, withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ACTION_UpdateMytCardInfo } from "#actions/update-myt-card-info";
|
||||
import { SELECT_MYT_CARD_INFO, ToMytCardInfo } from "#lib/db-formats/myt-card-info";
|
||||
import { withKamaitachi, withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SELECT_INVITE, ToInviteDocument } from "#lib/db-formats/invite";
|
||||
import { GetTotalAllowedInvites } from "#lib/invites/invites";
|
||||
import { withInvitesEnabled, withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import { GetUsersWithIDs } from "#utils/user";
|
||||
import { sql } from "kysely";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ACTION_MarkAllNotificationsRead } from "#actions/mark-all-notifications
|
||||
import { SELECT_NOTIFICATION, ToNotificationDocument } from "#lib/db-formats/notification";
|
||||
import { withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@ import { withAuthedAsUser, withPermission, withRequestedUser } from "#lib/router
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { RequirePermissions } from "#server/middleware/auth";
|
||||
import { CreateMulterSingleUploadMiddleware } from "#server/middleware/multer-upload";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { REQ_GetTachiData } from "#utils/req-tachi-data";
|
||||
|
||||
import { GetUserFromParam, RequireAuthedAsUser } from "../middleware";
|
||||
|
||||
@@ -9,7 +9,7 @@ import { log } from "#lib/log/log";
|
||||
import { GetRivalIDs } from "#lib/rivals/rivals";
|
||||
import { withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
import {
|
||||
GetGoalSummary,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SELECT_SESSION_CALENDAR, ToSessionCalendarDocument } from "#lib/db-formats/session";
|
||||
import { withRequestedUser } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import DB from "#services/pg/db";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ACTION_UpdateUserSettings } from "#actions/update-user-settings";
|
||||
import { withRequestedUser, withSelf } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/_singleton";
|
||||
import { GetSettingsForUser } from "#utils/user";
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,7 @@ import DB from "#services/pg/db";
|
||||
import { UnixMillisecondsToISO8601 } from "#utils/time";
|
||||
import { GetOnlineCutoff } from "#utils/user";
|
||||
|
||||
import { API_V1_ROUTER } from "../router";
|
||||
import { API_V1_ROUTER } from "../_singleton";
|
||||
|
||||
/**
|
||||
* Search users.
|
||||
|
||||
@@ -159,6 +159,24 @@ app.use((req, res, next) => {
|
||||
|
||||
app.use(RequestLoggerMiddleware);
|
||||
|
||||
// Per-request timing for test-suite profiling. Enabled by TACHI_REQ_TIMING=1.
|
||||
// Writes one line per request to stderr with method, url, status, total ms
|
||||
// and an approximate "handler" budget (server-side time from middleware entry
|
||||
// to res.on('finish')). Cheap enough to leave in for ad-hoc profiling but
|
||||
// gated so it doesn't pollute the normal test log.
|
||||
if (Env.NODE_ENV === "test" && process.env.TACHI_REQ_TIMING === "1") {
|
||||
app.use((req, res, next) => {
|
||||
const t0 = performance.now();
|
||||
res.on("finish", () => {
|
||||
const ms = performance.now() - t0;
|
||||
process.stderr.write(
|
||||
`[reqtiming] ${req.method.padEnd(4)} ${res.statusCode} ${ms.toFixed(1).padStart(7)}ms ${req.originalUrl}\n`,
|
||||
);
|
||||
});
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
app.use("/", mainRouter);
|
||||
|
||||
// completely stolen from ktapi error handler
|
||||
|
||||
@@ -22,6 +22,30 @@ if (process.env.NODE_ENV === "test") {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// Track whether app code under test has actually touched the DB since the
|
||||
// last reset. vitest.setup.ts reads this via `globalThis` (a property read,
|
||||
// no import) so files that never load #services/pg/db skip resetDatabase
|
||||
// entirely - the biggest single source of per-file overhead in pure-unit
|
||||
// tests where the first beforeEach was paying ~2 s just to import this
|
||||
// module and run a probe query.
|
||||
const g = globalThis as unknown as {
|
||||
__tachi_pg_loaded?: boolean;
|
||||
__tachi_pg_used?: boolean;
|
||||
};
|
||||
g.__tachi_pg_loaded = true;
|
||||
const origConnect = pool.connect.bind(pool);
|
||||
const origQuery = pool.query.bind(pool) as (...args: unknown[]) => unknown;
|
||||
|
||||
pool.connect = ((...args: unknown[]) => {
|
||||
g.__tachi_pg_used = true;
|
||||
return (origConnect as (...a: unknown[]) => unknown)(...args);
|
||||
}) as typeof pool.connect;
|
||||
|
||||
pool.query = ((...args: unknown[]) => {
|
||||
g.__tachi_pg_used = true;
|
||||
return origQuery(...args);
|
||||
}) as typeof pool.query;
|
||||
}
|
||||
|
||||
const DB = new Kysely<Database>({
|
||||
|
||||
@@ -9,17 +9,17 @@ const connection = server.listen();
|
||||
log.debug("Connecting to Supertest...");
|
||||
const mockApi = supertest(connection);
|
||||
|
||||
/**
|
||||
* No-op. Many test files call this in their own `afterAll` to "close" the
|
||||
* supertest http.Server, but with `pool: "threads"` + `isolate: false`
|
||||
* (vitest.config.ts) a single listener is shared across every test file a
|
||||
* worker processes - a real `connection.close()` here would break every
|
||||
* subsequent file in the worker. Node tears the socket down on process exit,
|
||||
* which is sufficient for test mode. Kept exported for source compatibility
|
||||
* with the existing test suite.
|
||||
*/
|
||||
export function CloseServerConnection() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
connection.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
export default mockApi;
|
||||
|
||||
@@ -3,6 +3,25 @@ import DB from "#services/pg/db";
|
||||
|
||||
let minimalIidxChartCounter = 0;
|
||||
|
||||
// `bcrypt.hash` at the test-mode rounds (BCRYPT_SALT_ROUNDS=4, see config.ts)
|
||||
// is ~5 ms; in CI we have hundreds of `seedUser({ withCredential: true })`
|
||||
// calls across the suite, almost all of them with the same handful of
|
||||
// plaintexts (`"password123"` and friends). With `pool: "threads" +
|
||||
// isolate: false` this cache survives across every file a worker processes,
|
||||
// turning that into one hash per (plaintext, worker). The hash is a pure
|
||||
// function of plaintext + rounds for our purposes (the salt varies, but
|
||||
// tests only care that `PasswordCompare(plaintext, hash)` round-trips).
|
||||
const hashedPasswordCache = new Map<string, Promise<string>>();
|
||||
function cachedHashPassword(plaintext: string): Promise<string> {
|
||||
const cached = hashedPasswordCache.get(plaintext);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
const p = HashPassword(plaintext);
|
||||
hashedPasswordCache.set(plaintext, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a minimal `song` + `chart` row for `iidx` / `SP` (`game` = `iidx-sp`) so
|
||||
* goal/chart validation (`GetChartById`) succeeds in tests.
|
||||
@@ -86,7 +105,7 @@ export async function seedUser(opts?: SeedUserOpts) {
|
||||
const userId = Number(id);
|
||||
|
||||
if (opts?.withCredential) {
|
||||
const hashedPassword = await HashPassword(password);
|
||||
const hashedPassword = await cachedHashPassword(password);
|
||||
|
||||
await DB.insertInto("priv_account_credential")
|
||||
.values({ user_id: userId, email, password: hashedPassword })
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
@@ -7,6 +8,37 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
/** When set (e.g. `test:coverage:set-user-supporter`), enforce 100% coverage on that file only. */
|
||||
const coverageSupporterActionOnly = process.env.VITEST_COVERAGE_SUPPORTER_ACTION === "1";
|
||||
|
||||
/**
|
||||
* Coverage is opt-in: gated on `VITEST_COVERAGE=1` (CI's coverage job sets this,
|
||||
* `just coverage-report` flips it on too). Keeping coverage off in the default
|
||||
* developer/watch path is the single biggest vitest config lever - V8 coverage
|
||||
* adds substantial overhead per worker even when nobody is reading the report.
|
||||
*
|
||||
* The supporter-action focussed run also forces coverage on (it inspects a
|
||||
* specific file's percentages).
|
||||
*/
|
||||
const coverageEnabled = process.env.VITEST_COVERAGE === "1" || coverageSupporterActionOnly;
|
||||
|
||||
/** Allow CI to dial worker count down (default: all cores, capped to keep PG happy). */
|
||||
function envInt(name: string, fallback: number): number {
|
||||
const v = process.env[name];
|
||||
if (v === undefined || v === "") {
|
||||
return fallback;
|
||||
}
|
||||
const n = Number.parseInt(v, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
const cpuCount = os.cpus().length;
|
||||
// With `isolate: false` (see below) the per-file collect/setup cost is paid
|
||||
// once per worker, not once per file. Each additional worker therefore costs
|
||||
// a fresh ~2 s of module evaluation + a long-lived Postgres DB whose tables
|
||||
// accumulate bloat (autovacuum is off on the tmpfs test PG for speed) over
|
||||
// the ~25-30 files it processes. Cap at 8 to keep the tmpfs footprint
|
||||
// bounded; past ~8 threads on a normal box we are CPU-saturated anyway.
|
||||
const maxWorkers = envInt("VITEST_MAX_WORKERS", Math.min(cpuCount, 8));
|
||||
const minWorkers = envInt("VITEST_MIN_WORKERS", Math.min(maxWorkers, cpuCount));
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
// Map #* path aliases to src/* so vite-node resolves them correctly.
|
||||
@@ -18,6 +50,10 @@ export default defineConfig({
|
||||
],
|
||||
},
|
||||
|
||||
// Cache vite-node's transform results on disk so cold starts in CI (and in
|
||||
// fresh dev containers) re-use prior compilation work.
|
||||
cacheDir: path.resolve(__dirname, ".vite-cache"),
|
||||
|
||||
test: {
|
||||
passWithNoTests: true,
|
||||
|
||||
@@ -41,32 +77,93 @@ export default defineConfig({
|
||||
VERSION_DETAIL: "test-detail",
|
||||
},
|
||||
|
||||
// Parallel test execution - each worker gets its own isolated Postgres database.
|
||||
// Parallel test execution. Each worker is a thread (not a fork) - thread
|
||||
// startup is dramatically cheaper than process forking. `isolate: false`
|
||||
// keeps the module graph alive across every test file a worker
|
||||
// processes; with ~218 files split across ~8 worker threads, the
|
||||
// alternative (`isolate: true`) re-evaluates Kysely + the full Express
|
||||
// router tree ~218 times instead of ~8, which dominates the per-file
|
||||
// "fixed overhead" budget. Per-test DB state is still reset in
|
||||
// vitest.setup.ts's beforeEach, and the worker DB lifecycle is
|
||||
// per-worker (not per-file) so re-using the pool across files is safe.
|
||||
//
|
||||
// EXCEPTION: a handful of files use `vi.mock(...)` to swap out modules
|
||||
// like `bms-table-loader` or `tachi-common`. With `isolate: false` the
|
||||
// vite-node module cache is shared across files in a worker, which
|
||||
// breaks vitest's per-file mock scoping (the mock factory does not
|
||||
// reliably take effect against an already-cached module). Those files
|
||||
// are routed through a separate project that runs with the default
|
||||
// `isolate: true`. The split keeps the fast path fast without giving
|
||||
// up the correctness of `vi.mock` for the handful of files that need
|
||||
// it.
|
||||
fileParallelism: true,
|
||||
globalSetup: "./vitest.globalSetup.ts",
|
||||
setupFiles: "./vitest.setup.ts",
|
||||
// forks pool gives stronger process isolation between workers.
|
||||
pool: "forks",
|
||||
|
||||
coverage: {
|
||||
enabled: true,
|
||||
provider: "v8",
|
||||
// Defaults plus lcov; `json` emits coverage-final.json for tachi-coverage-tools.
|
||||
reporter: ["text", "html", "clover", "json", "lcov"],
|
||||
include: coverageSupporterActionOnly
|
||||
? ["src/actions/set-user-supporter-status.ts"]
|
||||
: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.test.ts", "src/**/*.bench.ts", "src/test-utils/**"],
|
||||
...(coverageSupporterActionOnly
|
||||
? {
|
||||
thresholds: {
|
||||
lines: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
statements: 100,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
pool: "threads",
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: false,
|
||||
isolate: false,
|
||||
useAtomics: true,
|
||||
minThreads: minWorkers,
|
||||
maxThreads: maxWorkers,
|
||||
},
|
||||
},
|
||||
maxWorkers,
|
||||
minWorkers,
|
||||
|
||||
// `vi.mock(...)` users - run with full per-file isolation.
|
||||
// Keep this list short; if it grows, revisit the mocking approach
|
||||
// (e.g. dependency injection) rather than expanding the isolated set.
|
||||
projects: [
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: "default",
|
||||
exclude: [
|
||||
"src/actions/bms-table-sync.test.ts",
|
||||
"src/actions/change-pfp.test.ts",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: "isolated",
|
||||
include: [
|
||||
"src/actions/bms-table-sync.test.ts",
|
||||
"src/actions/change-pfp.test.ts",
|
||||
],
|
||||
poolOptions: {
|
||||
threads: {
|
||||
isolate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
...(coverageEnabled
|
||||
? {
|
||||
coverage: {
|
||||
enabled: true,
|
||||
provider: "v8",
|
||||
// Defaults plus lcov; `json` emits coverage-final.json for tachi-coverage-tools.
|
||||
reporter: ["text", "html", "clover", "json", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.test.ts", "src/**/*.bench.ts", "src/test-utils/**"],
|
||||
...(coverageSupporterActionOnly
|
||||
? {
|
||||
thresholds: {
|
||||
lines: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
statements: 100,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: { coverage: { enabled: false } }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,10 +4,12 @@ import pg from "pg";
|
||||
|
||||
import { ensureTestCdnBucket } from "./src/test-utils/ensure-test-cdn-bucket";
|
||||
|
||||
const POSTGRES_HOST = "tachi-postgres";
|
||||
// See note in vitest.setup.ts about POSTGRES_TEST_HOST + tachi-postgres-test.
|
||||
const POSTGRES_HOST = process.env.POSTGRES_TEST_HOST ?? "tachi-postgres";
|
||||
const POSTGRES_USER = "tachi";
|
||||
const POSTGRES_PASS = "tachi";
|
||||
const TEMPLATE_DB = "tachi_server_test_template";
|
||||
const WORKER_DB_PREFIX = "tachi_server_test_";
|
||||
|
||||
/**
|
||||
* Installs per-test dirty-table tracking in the template DB so every worker
|
||||
@@ -70,14 +72,59 @@ async function installTestDirtyTracking(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops every leaked worker DB (`tachi_server_test_*` minus the template).
|
||||
*
|
||||
* With `isolate: false` (vitest.config.ts) the per-worker setup file does NOT
|
||||
* drop its DB on `afterAll` - that hook fires per test file and we share the
|
||||
* worker across many files. We sweep them here as part of the suite-wide
|
||||
* teardown, plus opportunistically at startup so stale DBs from a killed run
|
||||
* do not accumulate.
|
||||
*/
|
||||
async function dropLeakedWorkerDatabases(): Promise<void> {
|
||||
const client = new pg.Client({
|
||||
host: POSTGRES_HOST,
|
||||
user: POSTGRES_USER,
|
||||
password: POSTGRES_PASS,
|
||||
database: "postgres",
|
||||
});
|
||||
await client.connect();
|
||||
try {
|
||||
const res = await client.query<{ datname: string }>(
|
||||
`SELECT datname FROM pg_database WHERE datname LIKE $1 AND datname <> $2`,
|
||||
[`${WORKER_DB_PREFIX}%`, TEMPLATE_DB],
|
||||
);
|
||||
for (const { datname } of res.rows) {
|
||||
// Each step is best-effort: tachi-postgres-test runs with
|
||||
// `fsync=off + full_page_writes=off` for speed, so DROP DATABASE
|
||||
// storms occasionally trip `checkpoint request failed`. We sweep
|
||||
// again on the next run's globalSetup, so logging is enough.
|
||||
try {
|
||||
await client.query(
|
||||
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1`,
|
||||
[datname],
|
||||
);
|
||||
await client.query(`DROP DATABASE IF EXISTS "${datname}"`);
|
||||
} catch (err) {
|
||||
console.warn(`[vitest-globalSetup] failed to drop ${datname}:`, err);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global vitest setup - runs ONCE before any workers start.
|
||||
*
|
||||
* Creates a fully-migrated template database and installs per-test dirty-table
|
||||
* tracking on it. Workers clone from it instead of running migrations
|
||||
* themselves, which is much faster.
|
||||
*
|
||||
* Returns a teardown function (Vitest runs this when the entire suite exits)
|
||||
* that sweeps every leaked `tachi_server_test_*` worker DB.
|
||||
*/
|
||||
export default async function globalSetup() {
|
||||
export default async function globalSetup(): Promise<() => Promise<void>> {
|
||||
const timing = process.env.TACHI_VITEST_TIMING === "1";
|
||||
const t0 = performance.now();
|
||||
execSync("just server-db-test-template-reset", { stdio: "inherit" });
|
||||
@@ -85,13 +132,26 @@ export default async function globalSetup() {
|
||||
await installTestDirtyTracking();
|
||||
const t1b = performance.now();
|
||||
await ensureTestCdnBucket();
|
||||
const t1c = performance.now();
|
||||
// Sweep stale worker DBs from prior killed runs before workers start cloning.
|
||||
await dropLeakedWorkerDatabases();
|
||||
const t2 = performance.now();
|
||||
if (timing) {
|
||||
const resetMs = t1 - t0;
|
||||
const dirtyMs = t1b - t1;
|
||||
const cdnMs = t2 - t1b;
|
||||
const cdnMs = t1c - t1b;
|
||||
const sweepMs = t2 - t1c;
|
||||
console.error(
|
||||
`[vitest-timing] globalSetup: template_reset_ms=${resetMs.toFixed(1)} install_dirty_tracking_ms=${dirtyMs.toFixed(1)} ensure_test_cdn_bucket_ms=${cdnMs.toFixed(1)} total_ms=${(t2 - t0).toFixed(1)}`,
|
||||
`[vitest-timing] globalSetup: template_reset_ms=${resetMs.toFixed(1)} install_dirty_tracking_ms=${dirtyMs.toFixed(1)} ensure_test_cdn_bucket_ms=${cdnMs.toFixed(1)} sweep_leaked_dbs_ms=${sweepMs.toFixed(1)} total_ms=${(t2 - t0).toFixed(1)}`,
|
||||
);
|
||||
}
|
||||
return async () => {
|
||||
const tTd0 = performance.now();
|
||||
await dropLeakedWorkerDatabases();
|
||||
if (timing) {
|
||||
console.error(
|
||||
`[vitest-timing] globalTeardown: sweep_leaked_dbs_ms=${(performance.now() - tTd0).toFixed(1)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
/**
|
||||
* Vitest per-worker setup for parallel test execution.
|
||||
*
|
||||
* Each worker gets its own isolated Postgres database cloned from the
|
||||
* template created in vitest.globalSetup.ts.
|
||||
* Each WORKER (not file) gets its own isolated Postgres database cloned from
|
||||
* the template created in vitest.globalSetup.ts. With `pool: "threads"` and
|
||||
* `isolate: false`, this module evaluates ONCE per worker thread but the
|
||||
* lifecycle callbacks below still fire per test file - that asymmetry is
|
||||
* fundamental to the perf win of `isolate: false`. As a result:
|
||||
*
|
||||
* - The per-worker DB is created lazily on the first file that needs it.
|
||||
* - Cleanup (close pool, close mock-api, DROP DATABASE) deliberately does
|
||||
* NOT happen in `afterAll` - that would kill shared resources mid-worker.
|
||||
* Worker DBs are swept by vitest.globalSetup.ts's teardown.
|
||||
* - `beforeEach` still runs per test; it TRUNCATEs whatever the previous
|
||||
* test dirtied via the trigger-based tracker, gated on whether any app
|
||||
* code in this worker has actually touched the DB.
|
||||
*
|
||||
* IMPORTANT: process.env assignments at the top level of this module run
|
||||
* before the test file's module graph is resolved, so app code that reads
|
||||
@@ -14,7 +25,10 @@ import crypto from "node:crypto";
|
||||
const WORKER_ID = crypto.randomUUID().slice(0, 8);
|
||||
const WORKER_DB_NAME = `tachi_server_test_${WORKER_ID}`;
|
||||
|
||||
const POSTGRES_HOST = "tachi-postgres";
|
||||
// `tachi-postgres-test` (tmpfs + fsync=off, see docker-compose-dev.yml) is the
|
||||
// preferred backend for tests. Fall back to the dev Postgres if the dedicated
|
||||
// service isn't running so a freshly-cloned repo still passes tests.
|
||||
const POSTGRES_HOST = process.env.POSTGRES_TEST_HOST ?? "tachi-postgres";
|
||||
const POSTGRES_USER = "tachi";
|
||||
const POSTGRES_PASS = "tachi";
|
||||
|
||||
@@ -63,25 +77,6 @@ async function createWorkerDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
async function dropWorkerDatabase() {
|
||||
const client = adminClient();
|
||||
|
||||
await client.connect();
|
||||
|
||||
try {
|
||||
// Terminate any open connections first so DROP DATABASE succeeds.
|
||||
await client.query(`
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = '${WORKER_DB_NAME}'
|
||||
`);
|
||||
|
||||
await client.query(`DROP DATABASE IF EXISTS "${WORKER_DB_NAME}"`);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDatabase() {
|
||||
// Lazily import so env vars are definitely set before the pool is created.
|
||||
const { default: db } = await import("#services/pg/db");
|
||||
@@ -100,7 +95,24 @@ async function resetDatabase() {
|
||||
const idents = ["_test_dirty_tables", ...dirty.rows.map((r) => r.table_name)].map((n) =>
|
||||
sql.id(n),
|
||||
);
|
||||
await sql`TRUNCATE TABLE ${sql.join(idents, sql`, `)} RESTART IDENTITY CASCADE`.execute(db);
|
||||
// TRUNCATE takes ACCESS EXCLUSIVE on every dirty table AND every table
|
||||
// reachable via FK CASCADE. With `pool: "threads"` + `isolate: false`
|
||||
// the mock-api supertest server is shared across files in a worker, so
|
||||
// an HTTP response from the previous `it()` can still be holding a row
|
||||
// lock on one of the cascade targets when `beforeEach` fires here. We
|
||||
// surface that as a fail-fast `lock_timeout` (set on the txn so it
|
||||
// applies to the TRUNCATE) instead of waiting `deadlock_timeout`
|
||||
// (1 s by default), then retry up to 5 times. CI exposes this maybe
|
||||
// 1-2 tests per ~1700 runs; locally it is invisible.
|
||||
await runTruncateWithRetry(async () => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await sql`SET LOCAL lock_timeout = '500ms'`.execute(trx);
|
||||
await sql`TRUNCATE TABLE ${sql.join(
|
||||
idents,
|
||||
sql`, `,
|
||||
)} RESTART IDENTITY CASCADE`.execute(trx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -111,8 +123,52 @@ async function resetDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const RETRYABLE_PG_CODES = new Set([
|
||||
"40P01", // deadlock_detected
|
||||
"55P03", // lock_not_available (lock_timeout)
|
||||
]);
|
||||
|
||||
async function runTruncateWithRetry(fn: () => Promise<void>): Promise<void> {
|
||||
const maxAttempts = 5;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
// Sequential retry of a single fail-fast op; awaiting in the loop
|
||||
// is the whole point.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await fn();
|
||||
return;
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string } | null)?.code;
|
||||
if (attempt === maxAttempts || code === undefined || !RETRYABLE_PG_CODES.has(code)) {
|
||||
throw err;
|
||||
}
|
||||
// Exponential backoff with jitter, capped: 20, 40, 80, 160 ms.
|
||||
const baseMs = 20 * 2 ** (attempt - 1);
|
||||
const sleepMs = baseMs + Math.floor(Math.random() * baseMs);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => setTimeout(resolve, sleepMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-worker DB creation is lazy: only paid by workers whose tests actually
|
||||
// load #services/pg/db. With `isolate: false` this still triggers on the first
|
||||
// DB-using file in the worker; pure-unit-only workers skip CREATE DATABASE
|
||||
// entirely. `__tachi_pg_loaded` is set in db.ts on module load.
|
||||
let workerDbCreatedHere = false;
|
||||
async function ensureWorkerDatabase() {
|
||||
if (workerDbCreatedHere) {
|
||||
return;
|
||||
}
|
||||
await createWorkerDatabase();
|
||||
workerDbCreatedHere = true;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const gFlags = globalThis as { __tachi_pg_loaded?: boolean };
|
||||
if (gFlags.__tachi_pg_loaded === true) {
|
||||
await ensureWorkerDatabase();
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async (ctx) => {
|
||||
@@ -130,51 +186,54 @@ beforeEach(async (ctx) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const tReset0 = performance.now();
|
||||
await resetDatabase();
|
||||
if (TIMING) {
|
||||
const d = performance.now() - tReset0;
|
||||
resetCalls += 1;
|
||||
msResetDatabaseTotal += d;
|
||||
msResetDatabaseMax = Math.max(msResetDatabaseMax, d);
|
||||
// Skip the TRUNCATE / cache-reset work when this worker has not yet loaded
|
||||
// the DB at all (`__tachi_pg_loaded`, set in db.ts) or has not issued any
|
||||
// query since the last reset (`__tachi_pg_used`, set by the pool wrappers
|
||||
// in db.ts). Both are plain property reads on globalThis - no import cost
|
||||
// when the conditions are false. Worth roughly 0.5-2 ms per pure-unit
|
||||
// test in a mixed worker, and the DROP DATABASE / pool init cost on
|
||||
// workers that happen to be pure-unit only.
|
||||
const g = globalThis as unknown as {
|
||||
__tachi_pg_loaded?: boolean;
|
||||
__tachi_pg_used?: boolean;
|
||||
};
|
||||
|
||||
if (g.__tachi_pg_loaded === true && g.__tachi_pg_used === true) {
|
||||
// Defensive: a test file that loads db.ts only inside an `it()` body
|
||||
// would skip our beforeAll gate, so re-check here.
|
||||
await ensureWorkerDatabase();
|
||||
const tReset0 = performance.now();
|
||||
await resetDatabase();
|
||||
g.__tachi_pg_used = false;
|
||||
if (TIMING) {
|
||||
const d = performance.now() - tReset0;
|
||||
resetCalls += 1;
|
||||
msResetDatabaseTotal += d;
|
||||
msResetDatabaseMax = Math.max(msResetDatabaseMax, d);
|
||||
}
|
||||
}
|
||||
// Login-heavy router tests share the in-memory login rate limiter; reset each
|
||||
// test so AggressiveRateLimit (15 / 10 min) does not 429 and omit Set-Cookie.
|
||||
const tRl0 = performance.now();
|
||||
const { ClearTestingRateLimitCache } = await import("#server/middleware/rate-limiter");
|
||||
ClearTestingRateLimitCache();
|
||||
if (TIMING) {
|
||||
msRateLimitCacheTotal += performance.now() - tRl0;
|
||||
|
||||
// Login-heavy router tests share the in-memory login rate limiter; reset
|
||||
// each test so AggressiveRateLimit (15 / 10 min) does not 429 and omit
|
||||
// Set-Cookie. `__tachi_rate_limiter_loaded` is set on module load in
|
||||
// rate-limiter.ts so we skip the import entirely on workers that never
|
||||
// touch any router.
|
||||
const gRl = globalThis as unknown as { __tachi_rate_limiter_loaded?: boolean };
|
||||
if (gRl.__tachi_rate_limiter_loaded === true) {
|
||||
const tRl0 = performance.now();
|
||||
const { ClearTestingRateLimitCache } = await import("#server/middleware/rate-limiter");
|
||||
ClearTestingRateLimitCache();
|
||||
if (TIMING) {
|
||||
msRateLimitCacheTotal += performance.now() - tRl0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
let msCloseMock = 0;
|
||||
let msClosePg = 0;
|
||||
let msDropDb = 0;
|
||||
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
const { CloseServerConnection } = await import("#test-utils/mock-api");
|
||||
await CloseServerConnection();
|
||||
msCloseMock = performance.now() - t0;
|
||||
} catch {
|
||||
// No mock HTTP server in this worker, or close failed.
|
||||
}
|
||||
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
const { ClosePgConnection } = await import("#services/pg/db");
|
||||
await ClosePgConnection();
|
||||
msClosePg = performance.now() - t0;
|
||||
} catch {
|
||||
// Pool may not have been initialised if no test ran a query.
|
||||
}
|
||||
|
||||
const tDrop0 = performance.now();
|
||||
await dropWorkerDatabase();
|
||||
msDropDb = performance.now() - tDrop0;
|
||||
|
||||
// Per-file afterAll is deliberately a no-op with `isolate: false` - closing
|
||||
// the pool or stopping the supertest server here would break subsequent
|
||||
// files in the same worker. Worker-wide cleanup (DROP DATABASE, pool/server
|
||||
// close) is the job of vitest.globalSetup.ts's teardown sweep + process exit.
|
||||
afterAll(() => {
|
||||
if (TIMING) {
|
||||
const wallMs = performance.now() - workerWallStart;
|
||||
const avgReset = resetCalls > 0 ? msResetDatabaseTotal / resetCalls : 0;
|
||||
@@ -187,9 +246,6 @@ afterAll(async () => {
|
||||
`reset_avg_ms=${avgReset.toFixed(1)}`,
|
||||
`reset_max_ms=${msResetDatabaseMax.toFixed(1)}`,
|
||||
`rate_limit_cache_reset_total_ms=${msRateLimitCacheTotal.toFixed(1)}`,
|
||||
`teardown_close_mock_ms=${msCloseMock.toFixed(1)}`,
|
||||
`teardown_close_pg_ms=${msClosePg.toFixed(1)}`,
|
||||
`teardown_drop_db_ms=${msDropDb.toFixed(1)}`,
|
||||
`worker_wall_ms=${wallMs.toFixed(1)}`,
|
||||
].join(" "),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user