From 95b01155b9fc76ceaabe04ac84feefb85293c8a6 Mon Sep 17 00:00:00 2001 From: zk Date: Wed, 20 May 2026 00:35:33 +0100 Subject: [PATCH] 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 --- .github/workflows/client.yml | 2 +- .github/workflows/seeds-webui.yml | 2 +- .github/workflows/server.yml | 40 +++- Justfile-db | 4 + Justfile-test | 16 +- docker-compose-dev.yml | 63 ++++++ .../src/app/pages/admin/AdminActionsPage.tsx | 8 +- .../src/app/pages/admin/AdminJobQueuePage.tsx | 17 +- typescript/client/src/lib/adminConstants.ts | 4 + typescript/server/.gitignore | 1 + .../server/src/lib/admin/admin-queries.ts | 24 ++- typescript/server/src/lib/auth/auth.ts | 6 +- typescript/server/src/lib/setup/config.ts | 21 ++ .../src/server/middleware/rate-limiter.ts | 8 + .../src/server/router/api/v1/_singleton.ts | 23 +++ .../server/router/api/v1/activity/router.ts | 2 +- .../src/server/router/api/v1/admin/router.ts | 2 +- .../src/server/router/api/v1/auth/router.ts | 2 +- .../server/router/api/v1/clients/router.ts | 2 +- .../src/server/router/api/v1/config/router.ts | 2 +- .../games/@gameSpecificRoutes/bms/router.ts | 2 +- .../games/@gameSpecificRoutes/iidx/router.ts | 2 +- .../src/server/router/api/v1/games/router.ts | 2 +- .../src/server/router/api/v1/import/router.ts | 2 +- .../server/router/api/v1/imports/router.ts | 2 +- .../server/router/api/v1/localdev/router.ts | 2 +- .../src/server/router/api/v1/oauth/router.ts | 2 +- .../server/router/api/v1/proposals/router.ts | 2 +- .../server/src/server/router/api/v1/router.ts | 123 +++++------ .../router/api/v1/scores/_scoreID/router.ts | 2 +- .../src/server/router/api/v1/search/router.ts | 2 +- .../src/server/router/api/v1/seeds/router.ts | 2 +- .../api/v1/sessions/_sessionID/router.ts | 2 +- .../src/server/router/api/v1/status/router.ts | 2 +- .../api/v1/users/_userID/api-tokens/router.ts | 2 +- .../api/v1/users/_userID/banner/router.ts | 2 +- .../api/v1/users/_userID/following/router.ts | 2 +- .../games/@gameSpecificRoutes/bms/router.ts | 2 +- .../games/@gameSpecificRoutes/iidx/router.ts | 2 +- .../@gameSpecificRoutes/jubeat/router.ts | 2 +- .../_playtype/folders/_folderSlug/router.ts | 2 +- .../games/_game/_playtype/folders/router.ts | 2 +- .../games/_game/_playtype/pbs/router.ts | 2 +- .../games/_game/_playtype/rivals/router.ts | 2 +- .../_userID/games/_game/_playtype/router.ts | 2 +- .../games/_game/_playtype/scores/router.ts | 2 +- .../games/_game/_playtype/sessions/router.ts | 2 +- .../games/_game/_playtype/settings/router.ts | 2 +- .../games/_game/_playtype/showcase/router.ts | 2 +- .../games/_game/_playtype/tables/router.ts | 2 +- .../_game/_playtype/targets/goals/router.ts | 2 +- .../_game/_playtype/targets/quests/router.ts | 2 +- .../games/_game/_playtype/targets/router.ts | 2 +- .../api/v1/users/_userID/imports/router.ts | 2 +- .../_userID/integrations/cg/_cgType/router.ts | 2 +- .../_userID/integrations/fervidex/router.ts | 2 +- .../integrations/kai/_kaiType/router.ts | 2 +- .../integrations/kshook-sv6c/router.ts | 2 +- .../users/_userID/integrations/myt/router.ts | 2 +- .../api/v1/users/_userID/invites/router.ts | 2 +- .../v1/users/_userID/notifications/router.ts | 2 +- .../router/api/v1/users/_userID/pfp/router.ts | 2 +- .../router/api/v1/users/_userID/router.ts | 2 +- .../api/v1/users/_userID/sessions/router.ts | 2 +- .../api/v1/users/_userID/settings/router.ts | 2 +- .../src/server/router/api/v1/users/router.ts | 2 +- typescript/server/src/server/server.ts | 18 ++ typescript/server/src/services/pg/db.ts | 24 +++ typescript/server/src/test-utils/mock-api.ts | 20 +- .../server/src/test-utils/pg-fixtures.ts | 21 +- typescript/server/vitest.config.ts | 143 ++++++++++--- typescript/server/vitest.globalSetup.ts | 68 ++++++- typescript/server/vitest.setup.ts | 192 +++++++++++------- 73 files changed, 710 insertions(+), 238 deletions(-) create mode 100644 typescript/server/src/server/router/api/v1/_singleton.ts diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 55ec5aa60..f4a1da077 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -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 diff --git a/.github/workflows/seeds-webui.yml b/.github/workflows/seeds-webui.yml index 3b2392023..9b92a61d3 100644 --- a/.github/workflows/seeds-webui.yml +++ b/.github/workflows/seeds-webui.yml @@ -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 diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index c751aa8eb..b02b15183 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -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 diff --git a/Justfile-db b/Justfile-db index 3bca98824..65863da07 100644 --- a/Justfile-db +++ b/Justfile-db @@ -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. diff --git a/Justfile-test b/Justfile-test index aa4c8432e..e2b0af51a 100644 --- a/Justfile-test +++ b/Justfile-test @@ -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 diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index bbc9e9b60..c04ed2095 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -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 diff --git a/typescript/client/src/app/pages/admin/AdminActionsPage.tsx b/typescript/client/src/app/pages/admin/AdminActionsPage.tsx index 00bffb274..7c0ba62c7 100644 --- a/typescript/client/src/app/pages/admin/AdminActionsPage.tsx +++ b/typescript/client/src/app/pages/admin/AdminActionsPage.tsx @@ -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() {

Actions {actions.total.toLocaleString()}

+

+ Actions from the last {ADMIN_RECENT_HOURS} hours (up to {pageSize} per page). +

diff --git a/typescript/client/src/app/pages/admin/AdminJobQueuePage.tsx b/typescript/client/src/app/pages/admin/AdminJobQueuePage.tsx index 7bfad3db2..bf2b2ab3f 100644 --- a/typescript/client/src/app/pages/admin/AdminJobQueuePage.tsx +++ b/typescript/client/src/app/pages/admin/AdminJobQueuePage.tsx @@ -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() {

Active jobs {activeJobs.length}

+ {activeJobsTruncated && ( +

+ Showing the first {ADMIN_PAGE_SIZE} running jobs (oldest scheduled + first). +

+ )}
@@ -146,9 +154,12 @@ export default function AdminJobQueuePage() {

- All jobs{" "} + Recent jobs{" "} {jobQueue.total.toLocaleString()}

+

+ Jobs created in the last {ADMIN_RECENT_HOURS} hours (up to {pageSize} per page). +

= { 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; diff --git a/typescript/server/.gitignore b/typescript/server/.gitignore index 475958279..e07682cb1 100644 --- a/typescript/server/.gitignore +++ b/typescript/server/.gitignore @@ -1,6 +1,7 @@ # Test stuffs .setup_env failed-tests.txt +.vite-cache # Error Files scripts/validate-database-errs/* diff --git a/typescript/server/src/lib/admin/admin-queries.ts b/typescript/server/src/lib/admin/admin-queries.ts index 98e6fd411..3768d75dd 100644 --- a/typescript/server/src/lib/admin/admin-queries.ts +++ b/typescript/server/src/lib/admin/admin-queries.ts @@ -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 { total: number; } -export function GetActiveJobs(): Promise> { +/** Currently running jobs (not limited by the recent-hours window). */ +export function GetActiveJobs(limit = ADMIN_PAGE_SIZE): Promise> { 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); } diff --git a/typescript/server/src/lib/auth/auth.ts b/typescript/server/src/lib/auth/auth.ts index 54a00c754..f79bb89e0 100644 --- a/typescript/server/src/lib/auth/auth.ts +++ b/typescript/server/src/lib/auth/auth.ts @@ -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( diff --git a/typescript/server/src/lib/setup/config.ts b/typescript/server/src/lib/setup/config.ts index 700c9855e..3f69d6b13 100644 --- a/typescript/server/src/lib/setup/config.ts +++ b/typescript/server/src/lib/setup/config.ts @@ -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, }; diff --git a/typescript/server/src/server/middleware/rate-limiter.ts b/typescript/server/src/server/middleware/rate-limiter.ts index 3c70c17ba..8f3b74601 100644 --- a/typescript/server/src/server/middleware/rate-limiter.ts +++ b/typescript/server/src/server/middleware/rate-limiter.ts @@ -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. diff --git a/typescript/server/src/server/router/api/v1/_singleton.ts b/typescript/server/src/server/router/api/v1/_singleton.ts new file mode 100644 index 000000000..aa2a318dd --- /dev/null +++ b/typescript/server/src/server/router/api/v1/_singleton.ts @@ -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); diff --git a/typescript/server/src/server/router/api/v1/activity/router.ts b/typescript/server/src/server/router/api/v1/activity/router.ts index d4423face..03de1a937 100644 --- a/typescript/server/src/server/router/api/v1/activity/router.ts +++ b/typescript/server/src/server/router/api/v1/activity/router.ts @@ -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( diff --git a/typescript/server/src/server/router/api/v1/admin/router.ts b/typescript/server/src/server/router/api/v1/admin/router.ts index 94ce3cef9..6d8057853 100644 --- a/typescript/server/src/server/router/api/v1/admin/router.ts +++ b/typescript/server/src/server/router/api/v1/admin/router.ts @@ -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); diff --git a/typescript/server/src/server/router/api/v1/auth/router.ts b/typescript/server/src/server/router/api/v1/auth/router.ts index dee01da39..0ff3f2e05 100644 --- a/typescript/server/src/server/router/api/v1/auth/router.ts +++ b/typescript/server/src/server/router/api/v1/auth/router.ts @@ -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); diff --git a/typescript/server/src/server/router/api/v1/clients/router.ts b/typescript/server/src/server/router/api/v1/clients/router.ts index 7586127ca..e32be0728 100644 --- a/typescript/server/src/server/router/api/v1/clients/router.ts +++ b/typescript/server/src/server/router/api/v1/clients/router.ts @@ -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. diff --git a/typescript/server/src/server/router/api/v1/config/router.ts b/typescript/server/src/server/router/api/v1/config/router.ts index cb524c7a2..e63c25104 100644 --- a/typescript/server/src/server/router/api/v1/config/router.ts +++ b/typescript/server/src/server/router/api/v1/config/router.ts @@ -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 diff --git a/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.ts b/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.ts index a5ecb24a1..4a8eeb8a0 100644 --- a/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.ts +++ b/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/bms/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/iidx/router.ts b/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/iidx/router.ts index 1cf8f5ae9..ee94bb8be 100644 --- a/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/iidx/router.ts +++ b/typescript/server/src/server/router/api/v1/games/@gameSpecificRoutes/iidx/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/games/router.ts b/typescript/server/src/server/router/api/v1/games/router.ts index 0b6ac216a..af02671a5 100644 --- a/typescript/server/src/server/router/api/v1/games/router.ts +++ b/typescript/server/src/server/router/api/v1/games/router.ts @@ -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(); diff --git a/typescript/server/src/server/router/api/v1/import/router.ts b/typescript/server/src/server/router/api/v1/import/router.ts index 0c792aa6a..11cefd32f 100644 --- a/typescript/server/src/server/router/api/v1/import/router.ts +++ b/typescript/server/src/server/router/api/v1/import/router.ts @@ -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); diff --git a/typescript/server/src/server/router/api/v1/imports/router.ts b/typescript/server/src/server/router/api/v1/imports/router.ts index 4ae923d0c..1342f223b 100644 --- a/typescript/server/src/server/router/api/v1/imports/router.ts +++ b/typescript/server/src/server/router/api/v1/imports/router.ts @@ -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 ───────────────────────────────────────────────── diff --git a/typescript/server/src/server/router/api/v1/localdev/router.ts b/typescript/server/src/server/router/api/v1/localdev/router.ts index c9df142bf..6b14b94ad 100644 --- a/typescript/server/src/server/router/api/v1/localdev/router.ts +++ b/typescript/server/src/server/router/api/v1/localdev/router.ts @@ -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). diff --git a/typescript/server/src/server/router/api/v1/oauth/router.ts b/typescript/server/src/server/router/api/v1/oauth/router.ts index 6fcf83192..343386fb5 100644 --- a/typescript/server/src/server/router/api/v1/oauth/router.ts +++ b/typescript/server/src/server/router/api/v1/oauth/router.ts @@ -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. diff --git a/typescript/server/src/server/router/api/v1/proposals/router.ts b/typescript/server/src/server/router/api/v1/proposals/router.ts index 7ef290d93..940ee1e2b 100644 --- a/typescript/server/src/server/router/api/v1/proposals/router.ts +++ b/typescript/server/src/server/router/api/v1/proposals/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/router.ts b/typescript/server/src/server/router/api/v1/router.ts index 46a7b27d7..5d1cfd3dd 100644 --- a/typescript/server/src/server/router/api/v1/router.ts +++ b/typescript/server/src/server/router/api/v1/router.ts @@ -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(); diff --git a/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts b/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts index e1ed8054b..e0d163ded 100644 --- a/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts +++ b/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/search/router.ts b/typescript/server/src/server/router/api/v1/search/router.ts index 9893bfc36..91890a71b 100644 --- a/typescript/server/src/server/router/api/v1/search/router.ts +++ b/typescript/server/src/server/router/api/v1/search/router.ts @@ -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. diff --git a/typescript/server/src/server/router/api/v1/seeds/router.ts b/typescript/server/src/server/router/api/v1/seeds/router.ts index 5dce2ea68..c80c1b43f 100644 --- a/typescript/server/src/server/router/api/v1/seeds/router.ts +++ b/typescript/server/src/server/router/api/v1/seeds/router.ts @@ -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. diff --git a/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.ts b/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.ts index ed2484add..e09fc1540 100644 --- a/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.ts +++ b/typescript/server/src/server/router/api/v1/sessions/_sessionID/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/status/router.ts b/typescript/server/src/server/router/api/v1/status/router.ts index 178ca2c26..b856fb2ab 100644 --- a/typescript/server/src/server/router/api/v1/status/router.ts +++ b/typescript/server/src/server/router/api/v1/status/router.ts @@ -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(); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts index 820bfb78c..613a2074d 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts @@ -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"; /** diff --git a/typescript/server/src/server/router/api/v1/users/_userID/banner/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/banner/router.ts index 05f0489b6..43c980e39 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/banner/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/banner/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/following/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/following/router.ts index 8327b5225..bbbbc967b 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/following/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/following/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts index 8c921d88a..ef945b868 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/iidx/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/iidx/router.ts index 9a940c8b7..3bbf92742 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/iidx/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/iidx/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/jubeat/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/jubeat/router.ts index 381818f95..9b90fc688 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/jubeat/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/jubeat/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.ts index 35a71ce7e..201a3ab4e 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/router.ts index 8a7ae829c..76a5d4199 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/router.ts @@ -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. diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts index 9b4b00fe3..5a28e3081 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts index 950bf272c..706f5dbb7 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts index 47fe92012..c73f58f7e 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.ts index 5d63d76fc..37c1f3b3b 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.ts @@ -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 { diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts index 12b5ad937..df91f9cc3 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts index c9a645e62..0848cf065 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts index 28de31fa3..fa6d808e1 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts @@ -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 { diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.ts index 01a881e3f..3306bcd4d 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.ts @@ -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"; /** diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts index 1606f1b97..bc2290186 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts index 998174737..057a93b7b 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts index 87eb56b55..3f4220704 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts @@ -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, diff --git a/typescript/server/src/server/router/api/v1/users/_userID/imports/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/imports/router.ts index a53a52ae1..b5fa2a8f6 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/imports/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/imports/router.ts @@ -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. diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/cg/_cgType/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/cg/_cgType/router.ts index 10e9b2eee..69618ee31 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/cg/_cgType/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/integrations/cg/_cgType/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.ts index 2d7c0b00e..2512e9276 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts index 0dd222b05..c9ab4c30d 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.ts index 440a38a7d..f87c58fcd 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kshook-sv6c/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/myt/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/myt/router.ts index 18d89fde5..a4b4ef690 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/myt/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/integrations/myt/router.ts @@ -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"; /** diff --git a/typescript/server/src/server/router/api/v1/users/_userID/invites/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/invites/router.ts index c983dc272..cb75ffdf3 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/invites/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/invites/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.ts index 56ec22fa8..929a688a7 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.ts @@ -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"; /** diff --git a/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.ts index 1ad2f9552..c1aa0b0ad 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.ts @@ -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"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/router.ts index 84292765d..d731162dd 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/router.ts @@ -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, diff --git a/typescript/server/src/server/router/api/v1/users/_userID/sessions/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/sessions/router.ts index 512ec2153..aaf0cd07c 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/sessions/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/sessions/router.ts @@ -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"; /** diff --git a/typescript/server/src/server/router/api/v1/users/_userID/settings/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/settings/router.ts index 21d7f58ce..3c257f9ea 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/settings/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/settings/router.ts @@ -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"; /** diff --git a/typescript/server/src/server/router/api/v1/users/router.ts b/typescript/server/src/server/router/api/v1/users/router.ts index 031545183..b949ce98f 100644 --- a/typescript/server/src/server/router/api/v1/users/router.ts +++ b/typescript/server/src/server/router/api/v1/users/router.ts @@ -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. diff --git a/typescript/server/src/server/server.ts b/typescript/server/src/server/server.ts index de225ef52..5365c71e3 100644 --- a/typescript/server/src/server/server.ts +++ b/typescript/server/src/server/server.ts @@ -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 diff --git a/typescript/server/src/services/pg/db.ts b/typescript/server/src/services/pg/db.ts index d320162c8..95c80479c 100644 --- a/typescript/server/src/services/pg/db.ts +++ b/typescript/server/src/services/pg/db.ts @@ -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({ diff --git a/typescript/server/src/test-utils/mock-api.ts b/typescript/server/src/test-utils/mock-api.ts index d3b0951e2..75a99720a 100644 --- a/typescript/server/src/test-utils/mock-api.ts +++ b/typescript/server/src/test-utils/mock-api.ts @@ -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((resolve, reject) => { - connection.close((err) => { - if (err) { - reject(err); - return; - } - - resolve(); - }); - }); + return Promise.resolve(); } export default mockApi; diff --git a/typescript/server/src/test-utils/pg-fixtures.ts b/typescript/server/src/test-utils/pg-fixtures.ts index 13a1847d7..157dcbce9 100644 --- a/typescript/server/src/test-utils/pg-fixtures.ts +++ b/typescript/server/src/test-utils/pg-fixtures.ts @@ -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>(); +function cachedHashPassword(plaintext: string): Promise { + 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 }) diff --git a/typescript/server/vitest.config.ts b/typescript/server/vitest.config.ts index 970f2c72c..5a046e57d 100644 --- a/typescript/server/vitest.config.ts +++ b/typescript/server/vitest.config.ts @@ -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 } }), }, }); diff --git a/typescript/server/vitest.globalSetup.ts b/typescript/server/vitest.globalSetup.ts index 4f7bb804f..464611812 100644 --- a/typescript/server/vitest.globalSetup.ts +++ b/typescript/server/vitest.globalSetup.ts @@ -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 { } } +/** + * 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 { + 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> { 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)}`, + ); + } + }; } diff --git a/typescript/server/vitest.setup.ts b/typescript/server/vitest.setup.ts index eb7990537..b10cf208a 100644 --- a/typescript/server/vitest.setup.ts +++ b/typescript/server/vitest.setup.ts @@ -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): Promise { + 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(" "), );